Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 9f71f3420c18d5800afb493b723c328d23c7ea12


Parents : a9cc4b9
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-19T13:46:43-05:00

feat: integrate RNS FileSync functionality, including handler setup, API endpoints, and frontend components for file synchronization

Changes

130 files changed, 9411 insertions(+), 111 deletions(-)


Diff

diff --git a/.dockerignore b/.dockerignore
index d218979d..15ef2d75 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -6,6 +6,13 @@ screenshots/
vendor/lxmfy/tests/
vendor/lxmfy/docs/
vendor/lxmfy/.gitea/
+vendor/rns_filesync/tests/
+vendor/rns_filesync/docs/
+vendor/rns_filesync/docker/
+vendor/rns_filesync/packaging/
+vendor/rns_filesync/sideband/
+vendor/rns_filesync/man/
+vendor/rns_filesync/scripts/
vendor/offline/
# Development files

diff --git a/MANIFEST.in b/MANIFEST.in
index 88fe8c85..1132e714 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -13,6 +13,13 @@ recursive-exclude vendor/offline *
recursive-exclude vendor/lxmfy/tests *
recursive-exclude vendor/lxmfy/docs *
recursive-exclude vendor/lxmfy/docker *
+recursive-exclude vendor/rns_filesync/tests *
+recursive-exclude vendor/rns_filesync/docs *
+recursive-exclude vendor/rns_filesync/docker *
+recursive-exclude vendor/rns_filesync/packaging *
+recursive-exclude vendor/rns_filesync/sideband *
+recursive-exclude vendor/rns_filesync/man *
+recursive-exclude vendor/rns_filesync/scripts *
recursive-exclude * __pycache__
recursive-exclude * *.py[co]

diff --git a/Taskfile.yml b/Taskfile.yml
index 84e6e3f5..399f3589 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -275,6 +275,7 @@ tasks:
tests/backend/test_rns_link_manager.py
tests/backend/test_rns_link_fuzzing.py
tests/backend/test_rns_link_plugin.py
+ tests/backend/test_rns_filesync_security.py
-q
- task: test:eect
@@ -283,6 +284,11 @@ tasks:
cmds:
- uv run pytest tests/backend/eect/packs -m eect -q --tb=short
+ test:filesync:security:
+ desc: Adversarial and Hypothesis fuzz tests for RNS FileSync
+ cmds:
+ - uv run pytest tests/backend/test_rns_filesync_security.py tests/backend/test_rns_filesync_handler.py -q --tb=short
+
test:lv:
desc: Live Validation ladder (set MESHCHAT_LIVE_VALIDATION=1 for L2-L3)
cmds:

diff --git a/android/.gitignore b/android/.gitignore
index 0cdc2998..507fbc8f 100644
--- a/android/.gitignore
+++ b/android/.gitignore
@@ -17,6 +17,7 @@ local.properties
# Gradle-synced Chaquopy Python tree (see app/build.gradle)
/app/src/main/python/meshchatx/
/app/src/main/python/lxmfy/
+/app/src/main/python/rns_filesync/
# libcodec2.so copied from vendor wheels (scripts/android/sync-codec2-jni-libs.sh)
/app/src/main/jniLibs/*/libcodec2.so

diff --git a/android/README.md b/android/README.md
index ce3d8a57..9ccb5250 100644
--- a/android/README.md
+++ b/android/README.md
@@ -28,7 +28,7 @@ cd android
./gradlew --no-daemon :app:assembleDebug :app:assembleRelease
```
-There is a **single** application variant (no product flavors). Gradle syncs the **entire** `meshchatx/` tree into `app/src/main/python/meshchatx/` (including `public/repository-server-bundled` for the in-app repository server), and syncs vendored **`vendor/lxmfy/lxmfy`** into `app/src/main/python/lxmfy/` (required for bots; not installed via Chaquopy pip). The `fetchRepositoryBundledWheels` task runs before sync when bundled wheels are missing; if repo root `dist/reticulum_meshchatx-*.whl` exists (e.g. from `python -m build --wheel -o dist .`), that wheel is preferred over PyPI for the bundled set.
+There is a **single** application variant (no product flavors). Gradle syncs the **entire** `meshchatx/` tree into `app/src/main/python/meshchatx/` (including `public/repository-server-bundled` for the in-app repository server), and syncs vendored **`vendor/lxmfy/lxmfy`** into `app/src/main/python/lxmfy/` (required for bots; not installed via Chaquopy pip) plus **`vendor/rns_filesync/rns_filesync`** into `app/src/main/python/rns_filesync/` (required for FileSync). The `fetchRepositoryBundledWheels` task runs before sync when bundled wheels are missing; if repo root `dist/reticulum_meshchatx-*.whl` exists (e.g. from `python -m build --wheel -o dist .`), that wheel is preferred over PyPI for the bundled set.
### Native ABIs (universal APK)

diff --git a/android/app/build.gradle b/android/app/build.gradle
index a025b6b5..af4797d4 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -6,6 +6,7 @@ plugins {
def repoRoot = rootProject.projectDir.parentFile
def meshchatSourceDir = new File(repoRoot, "meshchatx")
def lxmfySourceDir = new File(repoRoot, "vendor/lxmfy/lxmfy")
+def rnsFilesyncSourceDir = new File(repoRoot, "vendor/rns_filesync/rns_filesync")
def repositoryBundledWheelsDir = new File(meshchatSourceDir, "public/repository-server-bundled/bundled")
def chaquopyBuildPython = System.getenv("CHAQUOPY_BUILD_PYTHON")
def pythonTempDir = new File(buildDir, "python-tmp")
@@ -268,9 +269,37 @@ tasks.register("syncLxmfyPython", Sync) {
into(file("$projectDir/src/main/python/lxmfy"))
}
+/*
+ * Desktop installs rns_filesync via setuptools (pyproject where includes vendor/rns_filesync).
+ * Chaquopy only sees android/app/src/main/python plus pip installs, so the
+ * vendored package must be synced here or FileSync imports fail at boot.
+ */
+tasks.register("syncRnsFilesyncPython", Sync) {
+ if (!rnsFilesyncSourceDir.exists()) {
+ throw new org.gradle.api.GradleException(
+ "Missing vendored rns_filesync package at ${rnsFilesyncSourceDir}. " +
+ "Expected vendor/rns_filesync/rns_filesync from the MeshChatX tree."
+ )
+ }
+ def initPy = new File(rnsFilesyncSourceDir, "__init__.py")
+ if (!initPy.exists()) {
+ throw new org.gradle.api.GradleException("Missing ${initPy}; vendored rns_filesync tree looks incomplete.")
+ }
+ doFirst {
+ delete(file("$projectDir/src/main/python/rns_filesync"))
+ }
+ from(rnsFilesyncSourceDir)
+ includeEmptyDirs = false
+ into(file("$projectDir/src/main/python/rns_filesync"))
+}
+
tasks.configureEach { task ->
if (task.name.toLowerCase().contains("merge") && task.name.toLowerCase().contains("pythonsources")) {
- task.dependsOn(tasks.named("syncMeshchatPython"), tasks.named("syncLxmfyPython"))
+ task.dependsOn(
+ tasks.named("syncMeshchatPython"),
+ tasks.named("syncLxmfyPython"),
+ tasks.named("syncRnsFilesyncPython"),
+ )
}
}

diff --git a/cx_setup.py b/cx_setup.py
index 3700bfeb..54551386 100644
--- a/cx_setup.py
+++ b/cx_setup.py
@@ -8,6 +8,9 @@ ROOT = Path(__file__).resolve().parent
_vendor_lxmfy = ROOT / "vendor" / "lxmfy"
if _vendor_lxmfy.is_dir() and (_vendor_lxmfy / "lxmfy").is_dir():
sys.path.insert(0, str(_vendor_lxmfy))
+_vendor_rns_filesync = ROOT / "vendor" / "rns_filesync"
+if _vendor_rns_filesync.is_dir() and (_vendor_rns_filesync / "rns_filesync").is_dir():
+ sys.path.insert(0, str(_vendor_rns_filesync))
from cx_Freeze import Executable, setup # noqa: E402
@@ -59,6 +62,7 @@ packages = [
"LXMF",
"LXST",
"lxmfy",
+ "rns_filesync",
"websockets",
"pycparser",
"cffi",

diff --git a/docs/agents/overview.md b/docs/agents/overview.md
index 4210c8a9..c39b4990 100644
--- a/docs/agents/overview.md
+++ b/docs/agents/overview.md
@@ -80,7 +80,7 @@ Critical lifecycle facts:
| `tests/frontend/` | vitest |
| `tests/e2e/` | Playwright |
| `docs/en/` | In-app / shipped English docs |
-| `vendor/` | Vendored deps (for example LXMFy) |
+| `vendor/` | Vendored deps (LXMFy, RNS FileSync) |
| `Taskfile.yml` | Preferred command entrypoints |
| `docs/agents/` | Agent guidance (this tree) |
| `AGENTS.md` | Short pointer to `docs/agents/` |

diff --git a/docs/agents/skills/android-webview-bridge/SKILL.md b/docs/agents/skills/android-webview-bridge/SKILL.md
index 3f0c0a8a..8025621d 100644
--- a/docs/agents/skills/android-webview-bridge/SKILL.md
+++ b/docs/agents/skills/android-webview-bridge/SKILL.md
@@ -24,7 +24,7 @@ Keep Chaquopy backend boot, WebView file choosers, storage locks, and external n
## Navigation and packaging
- External http(s) links open in the system browser. Do not navigate the WebView away from the app.
-- Vendored `lxmfy` is synced into Chaquopy `src/main/python/`. Android pip does not install it like desktop setuptools.
+- Vendored `lxmfy` and `rns_filesync` are synced into Chaquopy `src/main/python/`. Android pip does not install them like desktop setuptools.
- RNS panic containment matters on Android (see `deferred-network-startup`).
## RNode on Android

diff --git a/docs/en/tools.md b/docs/en/tools.md
index b35d3ec9..8026202c 100644
--- a/docs/en/tools.md
+++ b/docs/en/tools.md
@@ -17,12 +17,15 @@ Use these when messages or pages fail despite interfaces showing as enabled.
## File transfer and shell
-| Tool | Purpose |
-| ---- | ------------------------------------------ |
-| RNCP | Send or fetch files over Reticulum |
-| RNSH | Remote shell sessions with streamed output |
+| Tool | Purpose |
+| ------------ | ---------------------------------------------------- |
+| RNCP | Send or fetch files over Reticulum |
+| RNS FileSync | Sync a directory with peers over `rns_filesync.filesync` |
+| RNSH | Remote shell sessions with streamed output |
RNCP progress events arrive on the WebSocket as `rncp.transfer.progress`.
+FileSync progress and peer events use `filesync.sync.progress`, `filesync.peer.connected`, `filesync.peer.disconnected`, `filesync.file.updated`, `filesync.file.deleted`, and `filesync.error`.
+FileSync uses the bundled `rns_filesync` package and keeps sync state under the active identity storage directory.
## Messaging helpers
@@ -69,7 +72,7 @@ Translator calls respect **privacy mode**. When privacy mode blocks outbound HTT
## Coming soon
-The registry marks **RNS Tunnel** and **RNS FileSync** as coming soon. They do not have routes in the current release.
+The registry marks **RNS Tunnel** as coming soon. It does not have a route in the current release.
## Relay chat server

diff --git a/meshchatx.rsm b/meshchatx.rsm
index 48ea0950..5fb7207d 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 019ec8f8..2712b816 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -741,6 +741,17 @@ class ReticulumMeshChat:
if self.current_context:
self.current_context.rncp_handler = value
+ @property
+ def rns_filesync_handler(self):
+ return (
+ self.current_context.rns_filesync_handler if self.current_context else None
+ )
+
+ @rns_filesync_handler.setter
+ def rns_filesync_handler(self, value):
+ if self.current_context:
+ self.current_context.rns_filesync_handler = value
+
@property
def rnsh_manager(self):
return self.current_context.rnsh_manager if self.current_context else None
@@ -2892,6 +2903,15 @@ class ReticulumMeshChat:
except Exception:
pass
+ if top_level == "rns_filesync":
+ try:
+ module = importlib.import_module("rns_filesync")
+ ver = getattr(module, "__version__", None)
+ if ver:
+ return str(ver)
+ except Exception:
+ pass
+
embedded_specs: dict[str, tuple[str, str]] = {
"aiohttp": ("aiohttp", "__version__"),
"aiohttp-session": ("aiohttp_session", "__version__"),
@@ -2901,6 +2921,8 @@ class ReticulumMeshChat:
"bcrypt": ("bcrypt", "__version__"),
"ply": ("ply", "__version__"),
"lxmfy": ("lxmfy", "__version__"),
+ "rns-filesync": ("rns_filesync", "__version__"),
+ "rns_filesync": ("rns_filesync", "__version__"),
}
if package_name in embedded_specs:
mod_name, attr = embedded_specs[package_name]
@@ -8082,6 +8104,7 @@ class ReticulumMeshChat:
"ply": self.get_package_version("ply"),
"bcrypt": self.get_package_version("bcrypt"),
"lxmfy": self.get_package_version("lxmfy"),
+ "rns_filesync": self.get_package_version("rns-filesync"),
},
"storage_path": self.storage_path,
"database_path": _safe_database_path(),
@@ -13541,6 +13564,250 @@ class ReticulumMeshChat:
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
+ # --- RNS FileSync ---
+
+ def _filesync_require_handler():
+ return self._require_rns_tool_handler(
+ self.rns_filesync_handler,
+ "RNS FileSync",
+ )
+
+ @routes.get("/api/v1/filesync/status")
+ async def filesync_status(_request):
+ not_ready = _filesync_require_handler()
+ if not_ready is not None:
+ return not_ready
+ return web.json_response(self.rns_filesync_handler.get_status())
+
+ @routes.post("/api/v1/filesync/start")
+ async def filesync_start(request):
+ not_ready = _filesync_require_handler()
+ if not_ready is not None:
+ return not_ready
+ data = {}
+ with contextlib.suppress(Exception):
+ data = await request.json()
+ if not isinstance(data, dict):
+ data = {}
+ try:
+ result = await asyncio.to_thread(
+ self.rns_filesync_handler.start,
+ sync_directory=data.get("sync_directory"),
+ monitor=data.get("monitor"),
+ announce_interval=data.get("announce_interval"),
+ )
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=500)
+ if not result.get("ok"):
+ return web.json_response(
+ {"message": result.get("error", "failed to start")},
+ status=400,
+ )
+ return web.json_response(result)
+
+ @routes.post("/api/v1/filesync/stop")
+ async def filesync_stop(_request):
+ not_ready = _filesync_require_handler()
+ if not_ready is not None:
+ return not_ready
+ try:
+ result = await asyncio.to_thread(self.rns_filesync_handler.stop)
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=500)
+ return web.json_response(result)
+
+ @routes.get("/api/v1/filesync/peers")
+ async def filesync_peers(_request):
+ not_ready = _filesync_require_handler()
+ if not_ready is not None:
+ return not_ready
+ return web.json_response({"peers": self.rns_filesync_handler.list_peers()})
+
+ @routes.get("/api/v1/filesync/files")
+ async def filesync_files(_request):
+ not_ready = _filesync_require_handler()
+ if not_ready is not None:
+ return not_ready
+ return web.json_response({"files": self.rns_filesync_handler.list_files()})
+
+ @routes.post("/api/v1/filesync/connect")
+ async def filesync_connect(request):
+ not_ready = _filesync_require_handler()
+ if not_ready is not None:
+ return not_ready
+ data = await request.json()
+ if not isinstance(data, dict):
+ return web.json_response({"message": "Invalid JSON body"}, status=400)
+ identity_hash = data.get("identity_hash", "")
+ try:
+ result = await asyncio.to_thread(
+ self.rns_filesync_handler.connect_peer,
+ identity_hash,
+ )
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=500)
+ if not result.get("ok"):
+ return web.json_response(
+ {"message": result.get("error", "connect failed"), **result},
+ status=400,
+ )
+ return web.json_response(result)
+
+ @routes.post("/api/v1/filesync/disconnect")
+ async def filesync_disconnect(request):
+ not_ready = _filesync_require_handler()
+ if not_ready is not None:
+ return not_ready
+ data = await request.json()
+ if not isinstance(data, dict):
+ return web.json_response({"message": "Invalid JSON body"}, status=400)
+ peer_id = data.get("peer_id", "")
+ try:
+ result = await asyncio.to_thread(
+ self.rns_filesync_handler.disconnect_peer,
+ peer_id,
+ )
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=500)
+ if not result.get("ok"):
+ return web.json_response(
+ {"message": result.get("error", "disconnect failed")},
+ status=400,
+ )
+ return web.json_response(result)
+
+ @routes.post("/api/v1/filesync/announce")
+ async def filesync_announce(_request):
+ not_ready = _filesync_require_handler()
+ if not_ready is not None:
+ return not_ready
+ try:
+ result = await asyncio.to_thread(self.rns_filesync_handler.announce_now)
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=500)
+ if not result.get("ok"):
+ return web.json_response(
+ {"message": result.get("error", "announce failed")},
+ status=400,
+ )
+ return web.json_response(result)
+
+ @routes.post("/api/v1/filesync/browse")
+ async def filesync_browse(request):
+ not_ready = _filesync_require_handler()
+ if not_ready is not None:
+ return not_ready
+ data = await request.json()
+ if not isinstance(data, dict):
+ return web.json_response({"message": "Invalid JSON body"}, status=400)
+ peer_id = data.get("peer_id", "")
+ timeout = data.get("timeout", 10.0)
+ try:
+ result = await asyncio.to_thread(
+ self.rns_filesync_handler.browse_peer,
+ peer_id,
+ timeout,
+ )
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=500)
+ if not result.get("ok"):
+ return web.json_response(
+ {
+ "message": result.get("error", "browse failed"),
+ "files": result.get("files", []),
+ },
+ status=400,
+ )
+ return web.json_response(result)
+
+ @routes.post("/api/v1/filesync/download")
+ async def filesync_download(request):
+ not_ready = _filesync_require_handler()
+ if not_ready is not None:
+ return not_ready
+ data = await request.json()
+ if not isinstance(data, dict):
+ return web.json_response({"message": "Invalid JSON body"}, status=400)
+ peer_id = data.get("peer_id", "")
+ path = data.get("path", "")
+ try:
+ result = await asyncio.to_thread(
+ self.rns_filesync_handler.download_file,
+ peer_id,
+ path,
+ )
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=500)
+ if not result.get("ok"):
+ return web.json_response(
+ {"message": result.get("error", "download failed"), **result},
+ status=400,
+ )
+ return web.json_response(result)
+
+ @routes.get("/api/v1/filesync/acl")
+ async def filesync_acl_get(_request):
+ not_ready = _filesync_require_handler()
+ if not_ready is not None:
+ return not_ready
+ return web.json_response(self.rns_filesync_handler.get_acl())
+
+ @routes.post("/api/v1/filesync/acl")
+ async def filesync_acl_post(request):
+ not_ready = _filesync_require_handler()
+ if not_ready is not None:
+ return not_ready
+ data = await request.json()
+ if not isinstance(data, dict):
+ return web.json_response({"message": "Invalid JSON body"}, status=400)
+ perms = data.get("perms")
+ if perms is not None and not isinstance(perms, list):
+ return web.json_response(
+ {"message": "perms must be a list"},
+ status=400,
+ )
+ try:
+ result = await asyncio.to_thread(
+ self.rns_filesync_handler.update_acl,
+ identity_hash=data.get("identity_hash"),
+ perms=perms,
+ enforce=data.get("enforce"),
+ rules_text=data.get("rules_text"),
+ replace=bool(data.get("replace", False)),
+ )
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=500)
+ if not result.get("ok"):
+ return web.json_response(
+ {"message": result.get("error", "acl update failed")},
+ status=400,
+ )
+ return web.json_response(result)
+
+ @routes.patch("/api/v1/filesync/settings")
+ async def filesync_settings(request):
+ not_ready = _filesync_require_handler()
+ if not_ready is not None:
+ return not_ready
+ data = await request.json()
+ if not isinstance(data, dict):
+ return web.json_response({"message": "Invalid JSON body"}, status=400)
+ try:
+ result = await asyncio.to_thread(
+ self.rns_filesync_handler.update_settings,
+ sync_directory=data.get("sync_directory"),
+ monitor=data.get("monitor"),
+ announce_interval=data.get("announce_interval"),
+ )
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=500)
+ if not result.get("ok"):
+ return web.json_response(
+ {"message": result.get("error", "settings update failed")},
+ status=400,
+ )
+ return web.json_response(result)
+
# --- Plugin API ---
@routes.get("/api/v1/plugins")

diff --git a/meshchatx/src/backend/data/THIRD_PARTY_NOTICES.txt b/meshchatx/src/backend/data/THIRD_PARTY_NOTICES.txt
index f65ea9f5..6c7c621d 100644
--- a/meshchatx/src/backend/data/THIRD_PARTY_NOTICES.txt
+++ b/meshchatx/src/backend/data/THIRD_PARTY_NOTICES.txt
@@ -52,6 +52,9 @@ lxmf 1.0.1
lxmfy 1.6.5
License: BSD-0-Clause
Author: Quad4 <team@quad4.io>
+rns-filesync 1.0.0
+ License: BSD-2-Clause
+ Author: Sudo-Ivan / Quad4.io
lxst 0.5.0
License: Other/Proprietary License
Author: Mark Qvist

diff --git a/meshchatx/src/backend/data/licenses_backend.json b/meshchatx/src/backend/data/licenses_backend.json
index b5df266f..e79c86da 100644
--- a/meshchatx/src/backend/data/licenses_backend.json
+++ b/meshchatx/src/backend/data/licenses_backend.json
@@ -95,6 +95,12 @@
"author": "Quad4 <team@quad4.io>",
"license": "BSD-0-Clause"
},
+ {
+ "name": "rns-filesync",
+ "version": "1.0.0",
+ "author": "Sudo-Ivan / Quad4.io",
+ "license": "BSD-2-Clause"
+ },
{
"name": "lxst",
"version": "0.5.0",

diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py
index 9a289bd4..c560838f 100644
--- a/meshchatx/src/backend/identity_context.py
+++ b/meshchatx/src/backend/identity_context.py
@@ -34,6 +34,7 @@ from meshchatx.src.backend.rnpath_handler import RNPathHandler
from meshchatx.src.backend.rnpath_trace_handler import RNPathTraceHandler
from meshchatx.src.backend.rnprobe_handler import RNProbeHandler
from meshchatx.src.backend.rnsh_manager import RNSHManager
+from meshchatx.src.backend.rns_filesync_handler import RnsFilesyncHandler
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
from meshchatx.src.backend.rnx_manager import RNXManager
from meshchatx.src.backend.rrc import RRCManager, RRCServerManager
@@ -93,6 +94,7 @@ class IdentityContext:
self.notification_sound_manager = None
self.auto_propagation_manager = None
self.rncp_handler = None
+ self.rns_filesync_handler = None
self.rnsh_manager = None
self.rnx_manager = None
self.rnstatus_handler = None
@@ -135,6 +137,14 @@ class IdentityContext:
except Exception:
pass
+ def _filesync_emit(self, message):
+ try:
+ from meshchatx.src.backend.async_utils import AsyncUtils
+
+ AsyncUtils.run_async(self.app._broadcast_websocket_message(message))
+ except Exception:
+ pass
+
def setup(self):
print(f"Setting up Identity Context for {self.identity_hash}...")
@@ -306,6 +316,12 @@ class IdentityContext:
storage_dir=self.app.storage_dir,
)
self.rncp_handler.on_receive_completed = self._rncp_emit_receive_completed
+ self.rns_filesync_handler = RnsFilesyncHandler(
+ reticulum_instance=getattr(self.app, "reticulum", None),
+ identity=self.identity,
+ storage_dir=self.storage_path,
+ emit_callback=self._filesync_emit,
+ )
self.rnsh_manager = RNSHManager(
storage_dir=self.storage_path,
reticulum_config_dir=getattr(self.app, "reticulum_config_dir", None),
@@ -733,6 +749,11 @@ class IdentityContext:
self.rncp_handler.teardown_receive_destination()
self.rncp_handler = None
+ if self.rns_filesync_handler:
+ with contextlib.suppress(Exception):
+ self.rns_filesync_handler.teardown()
+ self.rns_filesync_handler = None
+
self.rnstatus_handler = None
self.rnpath_handler = None
self.rnpath_trace_handler = None

diff --git a/meshchatx/src/backend/licenses_collector.py b/meshchatx/src/backend/licenses_collector.py
index 42ba33cb..3fb4ea7d 100644
--- a/meshchatx/src/backend/licenses_collector.py
+++ b/meshchatx/src/backend/licenses_collector.py
@@ -134,10 +134,19 @@ def _python_roots_from_pyproject(repo_root: Path) -> tuple[str, ...]:
return tuple(sorted(set(names), key=lambda n: n.lower()))
-def _bundled_lxmfy_license_row(repo_root: Path) -> dict[str, Any] | None:
- if _dist_for_requirement_name("lxmfy") is not None:
+def _bundled_vendor_license_row(
+ repo_root: Path,
+ *,
+ vendor_dir: str,
+ dist_name: str,
+ package_name: str | None = None,
+) -> dict[str, Any] | None:
+ lookup_name = package_name or dist_name
+ if _dist_for_requirement_name(lookup_name) is not None:
+ return None
+ if _dist_for_requirement_name(dist_name) is not None:
return None
- vp = repo_root / "vendor" / "lxmfy" / "pyproject.toml"
+ vp = repo_root / "vendor" / vendor_dir / "pyproject.toml"
if not vp.is_file():
return None
try:
@@ -149,7 +158,7 @@ def _bundled_lxmfy_license_row(repo_root: Path) -> dict[str, Any] | None:
if not isinstance(proj, dict):
return None
name = proj.get("name")
- if name != "lxmfy":
+ if name != dist_name and name != lookup_name:
return None
version = proj.get("version")
version_s = version.strip() if isinstance(version, str) and version.strip() else "—"
@@ -165,36 +174,72 @@ def _bundled_lxmfy_license_row(repo_root: Path) -> dict[str, Any] | None:
elif an or ae:
author = an or ae
lic = proj.get("license")
- license_s = lic.strip() if isinstance(lic, str) and lic.strip() else "—"
+ if isinstance(lic, dict):
+ license_s = str(lic.get("text") or lic.get("file") or "—").strip() or "—"
+ elif isinstance(lic, str) and lic.strip():
+ license_s = lic.strip()
+ else:
+ license_s = "—"
return {
- "name": "lxmfy",
+ "name": dist_name,
"version": version_s,
"author": author,
"license": license_s,
}
-def _merge_bundled_lxmfy(
+def _bundled_lxmfy_license_row(repo_root: Path) -> dict[str, Any] | None:
+ return _bundled_vendor_license_row(
+ repo_root,
+ vendor_dir="lxmfy",
+ dist_name="lxmfy",
+ )
+
+
+def _bundled_rns_filesync_license_row(repo_root: Path) -> dict[str, Any] | None:
+ return _bundled_vendor_license_row(
+ repo_root,
+ vendor_dir="rns_filesync",
+ dist_name="rns-filesync",
+ package_name="rns_filesync",
+ )
+
+
+def _merge_bundled_vendor_rows(
repo_root: Path,
rows: list[dict[str, Any]],
) -> list[dict[str, Any]]:
- if any(str(r.get("name") or "").lower() == "lxmfy" for r in rows):
- return rows
- row = _bundled_lxmfy_license_row(repo_root)
- if row is None:
- return rows
- merged = [*rows, row]
+ merged = list(rows)
+ existing = {str(r.get("name") or "").lower() for r in merged}
+ for row_fn, names in (
+ (_bundled_lxmfy_license_row, ("lxmfy",)),
+ (_bundled_rns_filesync_license_row, ("rns-filesync", "rns_filesync")),
+ ):
+ if any(n in existing for n in names):
+ continue
+ row = row_fn(repo_root)
+ if row is None:
+ continue
+ merged.append(row)
+ existing.add(str(row.get("name") or "").lower())
merged.sort(key=lambda r: str(r.get("name", "")).lower())
return merged
+def _merge_bundled_lxmfy(
+ repo_root: Path,
+ rows: list[dict[str, Any]],
+) -> list[dict[str, Any]]:
+ return _merge_bundled_vendor_rows(repo_root, rows)
+
+
def _collect_backend_licenses_live() -> list[dict[str, Any]]:
repo = _repo_root()
for root in _ROOT_DIST_CANDIDATES:
if _dist_for_requirement_name(root) is not None:
- return _merge_bundled_lxmfy(repo, _collect_python_transitive((root,)))
+ return _merge_bundled_vendor_rows(repo, _collect_python_transitive((root,)))
roots = _python_roots_from_pyproject(repo)
- return _merge_bundled_lxmfy(repo, _collect_python_transitive(roots))
+ return _merge_bundled_vendor_rows(repo, _collect_python_transitive(roots))
def _load_embedded_backend_licenses() -> list[dict[str, Any]] | None:

diff --git a/meshchatx/src/backend/rns_filesync_handler.py b/meshchatx/src/backend/rns_filesync_handler.py
new file mode 100644
index 00000000..031e9a54
--- /dev/null
+++ b/meshchatx/src/backend/rns_filesync_handler.py
@@ -0,0 +1,400 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Identity-scoped wrapper around vendored rns_filesync.FileSyncService."""
+
+from __future__ import annotations
+
+import contextlib
+import json
+import os
+import threading
+from collections.abc import Callable
+from typing import Any
+
+from rns_filesync.constants import ANNOUNCE_INTERVAL_DEFAULT
+from rns_filesync.permissions import PermissionStore
+from rns_filesync.service import FileSyncService
+
+
+class RnsFilesyncHandler:
+ """Host FileSync against the shared Reticulum stack for one identity."""
+
+ def __init__(
+ self,
+ reticulum_instance,
+ identity,
+ storage_dir: str,
+ emit_callback: Callable[[dict[str, Any]], None] | None = None,
+ ):
+ self.reticulum = reticulum_instance
+ self.identity = identity
+ self.storage_dir = storage_dir
+ self._emit_callback = emit_callback
+ self._lock = threading.RLock()
+ self.service: FileSyncService | None = None
+
+ self._root = os.path.join(storage_dir, "filesync")
+ self._settings_path = os.path.join(self._root, "settings.json")
+ self._acl_path = os.path.join(self._root, "acl.txt")
+ self._sync_directory = os.path.join(self._root, "sync")
+ self._monitor = True
+ self._announce_interval = ANNOUNCE_INTERVAL_DEFAULT
+ self._load_settings()
+
+ def _emit(self, event_type: str, payload: dict[str, Any] | None = None) -> None:
+ if not self._emit_callback:
+ return
+ message = {"type": event_type}
+ if payload:
+ message.update(payload)
+ try:
+ self._emit_callback(message)
+ except Exception:
+ pass
+
+ def _default_sync_directory(self) -> str:
+ return os.path.join(self._root, "sync")
+
+ def _load_settings(self) -> None:
+ os.makedirs(self._root, exist_ok=True)
+ if not os.path.isfile(self._settings_path):
+ return
+ try:
+ with open(self._settings_path, encoding="utf-8") as handle:
+ data = json.load(handle)
+ except Exception:
+ return
+ if not isinstance(data, dict):
+ return
+ sync_dir = data.get("sync_directory")
+ if isinstance(sync_dir, str) and sync_dir.strip():
+ self._sync_directory = os.path.realpath(os.path.expanduser(sync_dir.strip()))
+ monitor = data.get("monitor")
+ if isinstance(monitor, bool):
+ self._monitor = monitor
+ interval = data.get("announce_interval")
+ if isinstance(interval, int) and interval >= 10:
+ self._announce_interval = interval
+
+ def _save_settings(self) -> None:
+ os.makedirs(self._root, exist_ok=True)
+ payload = {
+ "sync_directory": self._sync_directory,
+ "monitor": self._monitor,
+ "announce_interval": self._announce_interval,
+ }
+ tmp = f"{self._settings_path}.tmp"
+ with open(tmp, "w", encoding="utf-8") as handle:
+ json.dump(payload, handle, indent=2, sort_keys=True)
+ handle.write("\n")
+ os.replace(tmp, self._settings_path)
+
+ def _load_permissions(self) -> PermissionStore:
+ permissions = PermissionStore()
+ if os.path.isfile(self._acl_path):
+ with contextlib.suppress(Exception):
+ permissions.load_file(self._acl_path)
+ return permissions
+
+ def _save_acl(self, permissions: PermissionStore) -> None:
+ os.makedirs(self._root, exist_ok=True)
+ lines: list[str] = []
+ rules = permissions.as_dict()
+ for perm in ("read", "write", "delete"):
+ for target in rules.get(perm, []):
+ short = {"read": "r", "write": "w", "delete": "d"}[perm]
+ lines.append(f"{short}:{target}")
+ if permissions.enabled and not lines:
+ lines.append("# enforce=true")
+ elif not permissions.enabled:
+ lines.insert(0, "# enforce=false")
+ else:
+ lines.insert(0, "# enforce=true")
+ tmp = f"{self._acl_path}.tmp"
+ with open(tmp, "w", encoding="utf-8") as handle:
+ handle.write("\n".join(lines) + "\n")
+ os.replace(tmp, self._acl_path)
+
+ def _wire_callbacks(self, service: FileSyncService) -> None:
+ service.on_peer_connected = lambda payload: self._emit(
+ "filesync.peer.connected",
+ payload if isinstance(payload, dict) else {"peer": payload},
+ )
+ service.on_peer_disconnected = lambda payload: self._emit(
+ "filesync.peer.disconnected",
+ payload if isinstance(payload, dict) else {"peer": payload},
+ )
+ service.on_sync_progress = lambda payload: self._emit(
+ "filesync.sync.progress",
+ payload if isinstance(payload, dict) else {"progress": payload},
+ )
+ service.on_file_updated = lambda payload: self._emit(
+ "filesync.file.updated",
+ payload if isinstance(payload, dict) else {"path": payload},
+ )
+ service.on_file_deleted = lambda payload: self._emit(
+ "filesync.file.deleted",
+ payload if isinstance(payload, dict) else {"path": payload},
+ )
+ service.on_error = lambda payload: self._emit(
+ "filesync.error",
+ payload if isinstance(payload, dict) else {"error": str(payload)},
+ )
+
+ def _permissions(self) -> PermissionStore:
+ if self.service is not None:
+ return self.service.permissions
+ return self._load_permissions()
+
+ def get_status(self) -> dict[str, Any]:
+ with self._lock:
+ if self.service is not None and self.service.get_status().get("running"):
+ status = self.service.get_status()
+ status["monitor"] = self._monitor
+ status["announce_interval"] = self._announce_interval
+ status["config_directory"] = self._root
+ return status
+ return {
+ "running": False,
+ "sync_directory": self._sync_directory,
+ "identity_hash": (
+ self.identity.hash.hex()
+ if getattr(self.identity, "hash", None) is not None
+ else None
+ ),
+ "destination_hash": None,
+ "peers": 0,
+ "files": 0,
+ "whitelist": self._permissions().enabled,
+ "monitor": self._monitor,
+ "announce_interval": self._announce_interval,
+ "config_directory": self._root,
+ }
+
+ def start(
+ self,
+ *,
+ sync_directory: str | None = None,
+ monitor: bool | None = None,
+ announce_interval: int | None = None,
+ ) -> dict[str, Any]:
+ with self._lock:
+ if sync_directory is not None:
+ cleaned = str(sync_directory).strip()
+ if not cleaned:
+ return {"ok": False, "error": "sync_directory is required"}
+ self._sync_directory = os.path.realpath(os.path.expanduser(cleaned))
+ if monitor is not None:
+ self._monitor = bool(monitor)
+ if announce_interval is not None:
+ try:
+ interval = int(announce_interval)
+ except (TypeError, ValueError):
+ return {"ok": False, "error": "invalid announce_interval"}
+ if interval < 10:
+ return {"ok": False, "error": "announce_interval must be >= 10"}
+ self._announce_interval = interval
+
+ if self.service is not None:
+ status = self.service.get_status()
+ if status.get("running"):
+ return {"ok": True, "already_running": True, **status}
+
+ os.makedirs(self._sync_directory, exist_ok=True)
+ permissions = self._load_permissions()
+ if os.path.isfile(self._acl_path):
+ try:
+ with open(self._acl_path, encoding="utf-8") as handle:
+ first = handle.readline().strip()
+ if first == "# enforce=false":
+ permissions._enforce = False
+ except Exception:
+ pass
+
+ service = FileSyncService(
+ identity=self.identity,
+ sync_directory=self._sync_directory,
+ storage_dir=self._root,
+ reticulum=self.reticulum,
+ permissions=permissions,
+ own_reticulum=False,
+ )
+ self._wire_callbacks(service)
+ dest = service.start(
+ monitor=self._monitor,
+ announce_interval=self._announce_interval,
+ )
+ self.service = service
+ self._save_settings()
+ status = service.get_status()
+ return {
+ "ok": True,
+ "destination_hash": dest,
+ **status,
+ }
+
+ def stop(self) -> dict[str, Any]:
+ with self._lock:
+ if self.service is None:
+ return {"ok": True, "running": False}
+ with contextlib.suppress(Exception):
+ self.service.stop()
+ self.service = None
+ return {"ok": True, "running": False}
+
+ def teardown(self) -> None:
+ self.stop()
+
+ def list_peers(self) -> list[dict[str, Any]]:
+ with self._lock:
+ if self.service is None:
+ return []
+ return self.service.list_peers()
+
+ def list_files(self) -> list[dict[str, Any]]:
+ with self._lock:
+ if self.service is None:
+ return []
+ return self.service.list_files()
+
+ def connect_peer(self, identity_hash: str) -> dict[str, Any]:
+ with self._lock:
+ if self.service is None:
+ return {"ok": False, "error": "filesync is not running"}
+ cleaned = str(identity_hash or "").strip().lower().replace(":", "")
+ if not cleaned:
+ return {"ok": False, "error": "identity_hash is required"}
+ return self.service.connect_peer(cleaned)
+
+ def disconnect_peer(self, peer_id: str) -> dict[str, Any]:
+ with self._lock:
+ if self.service is None:
+ return {"ok": False, "error": "filesync is not running"}
+ cleaned = str(peer_id or "").strip()
+ if not cleaned:
+ return {"ok": False, "error": "peer_id is required"}
+ self.service.disconnect_peer(cleaned)
+ return {"ok": True, "peer_id": cleaned}
+
+ def announce_now(self) -> dict[str, Any]:
+ with self._lock:
+ if self.service is None:
+ return {"ok": False, "error": "filesync is not running"}
+ self.service.announce_now()
+ return {"ok": True}
+
+ def browse_peer(self, peer_id: str, timeout: float = 10.0) -> dict[str, Any]:
+ with self._lock:
+ if self.service is None:
+ return {"ok": False, "error": "filesync is not running", "files": []}
+ cleaned = str(peer_id or "").strip()
+ if not cleaned:
+ return {"ok": False, "error": "peer_id is required", "files": []}
+ try:
+ timeout_f = float(timeout)
+ except (TypeError, ValueError):
+ timeout_f = 10.0
+ files = self.service.browse_peer(cleaned, timeout=timeout_f)
+ return {"ok": True, "peer_id": cleaned, "files": files}
+
+ def download_file(self, peer_id: str, path: str) -> dict[str, Any]:
+ with self._lock:
+ if self.service is None:
+ return {"ok": False, "error": "filesync is not running"}
+ cleaned_peer = str(peer_id or "").strip()
+ cleaned_path = str(path or "").strip()
+ if not cleaned_peer:
+ return {"ok": False, "error": "peer_id is required"}
+ if not cleaned_path:
+ return {"ok": False, "error": "path is required"}
+ return self.service.download_file(cleaned_peer, cleaned_path)
+
+ def get_acl(self) -> dict[str, Any]:
+ with self._lock:
+ permissions = self._permissions()
+ return {
+ "enforce": permissions.enabled,
+ "rules": permissions.as_dict(),
+ }
+
+ def update_acl(
+ self,
+ *,
+ identity_hash: str | None = None,
+ perms: list[str] | None = None,
+ enforce: bool | None = None,
+ rules_text: str | None = None,
+ replace: bool = False,
+ ) -> dict[str, Any]:
+ with self._lock:
+ if replace or rules_text is not None:
+ permissions = PermissionStore()
+ if rules_text:
+ permissions.load_allowed_text(rules_text)
+ else:
+ permissions = self._permissions()
+ if self.service is None:
+ # Work on a fresh copy so we can persist even when stopped.
+ permissions = self._load_permissions()
+
+ if identity_hash and perms is not None:
+ granted = permissions.grant(identity_hash, perms)
+ if not granted and perms:
+ return {"ok": False, "error": "no valid permissions provided"}
+
+ if enforce is not None:
+ permissions._enforce = bool(enforce)
+
+ if self.service is not None:
+ self.service.permissions = permissions
+
+ self._save_acl(permissions)
+ return {
+ "ok": True,
+ "enforce": permissions.enabled,
+ "rules": permissions.as_dict(),
+ }
+
+ def update_settings(
+ self,
+ *,
+ sync_directory: str | None = None,
+ monitor: bool | None = None,
+ announce_interval: int | None = None,
+ ) -> dict[str, Any]:
+ with self._lock:
+ running = False
+ if self.service is not None:
+ running = bool(self.service.get_status().get("running"))
+
+ if sync_directory is not None:
+ if running:
+ return {
+ "ok": False,
+ "error": "stop filesync before changing sync directory",
+ }
+ cleaned = str(sync_directory).strip()
+ if not cleaned:
+ return {"ok": False, "error": "sync_directory is required"}
+ self._sync_directory = os.path.realpath(os.path.expanduser(cleaned))
+
+ if monitor is not None:
+ self._monitor = bool(monitor)
+
+ if announce_interval is not None:
+ try:
+ interval = int(announce_interval)
+ except (TypeError, ValueError):
+ return {"ok": False, "error": "invalid announce_interval"}
+ if interval < 10:
+ return {"ok": False, "error": "announce_interval must be >= 10"}
+ self._announce_interval = interval
+
+ self._save_settings()
+ return {
+ "ok": True,
+ "sync_directory": self._sync_directory,
+ "monitor": self._monitor,
+ "announce_interval": self._announce_interval,
+ "running": running,
+ }

diff --git a/meshchatx/src/backend/self_check.py b/meshchatx/src/backend/self_check.py
index ac9ab12e..34ae6473 100644
--- a/meshchatx/src/backend/self_check.py
+++ b/meshchatx/src/backend/self_check.py
@@ -20,6 +20,7 @@ _CRITICAL_IMPORTS = (
"RNS",
"LXMF",
"lxmfy",
+ "rns_filesync",
"aiohttp",
"bcrypt",
"cbor2",

diff --git a/meshchatx/src/frontend/components/filesync/RnsFilesyncPage.vue b/meshchatx/src/frontend/components/filesync/RnsFilesyncPage.vue
new file mode 100644
index 00000000..b6a32f23
--- /dev/null
+++ b/meshchatx/src/frontend/components/filesync/RnsFilesyncPage.vue
@@ -0,0 +1,620 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
+ <ToolsPageHeader
+ icon="folder-sync"
+ :title="$t('rns_filesync.title')"
+ :description="$t('rns_filesync.description')"
+ :eyebrow="$t('rns_filesync.eyebrow')"
+ accent="green"
+ />
+ <div
+ class="flex-1 overflow-y-auto w-full px-4 md:px-5 lg:px-8 py-6 pb-[max(1.5rem,env(safe-area-inset-bottom))]"
+ >
+ <div class="space-y-4 w-full max-w-4xl mx-auto">
+ <div class="glass-card space-y-5">
+ <div
+ class="p-4 rounded-lg bg-emerald-50/50 dark:bg-emerald-900/10 border border-emerald-100 dark:border-emerald-900/20"
+ >
+ <div
+ class="text-xs font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-2"
+ >
+ {{ $t("rns_filesync.usage_steps") }}
+ </div>
+ <div class="space-y-1.5 text-xs text-emerald-800/80 dark:text-emerald-300/80 leading-relaxed">
+ <p>{{ $t("rns_filesync.step_1") }}</p>
+ <p>{{ $t("rns_filesync.step_2") }}</p>
+ <p>{{ $t("rns_filesync.step_3") }}</p>
+ </div>
+ </div>
+
+ <div
+ class="border-b border-gray-200 dark:border-zinc-700 overflow-x-auto overscroll-x-contain -mx-4 px-4 sm:mx-0 sm:px-0"
+ >
+ <div class="flex w-max min-w-full sm:w-auto gap-1 sm:gap-2">
+ <button
+ v-for="tab in tabs"
+ :key="tab.id"
+ type="button"
+ :class="[
+ activeTab === tab.id
+ ? 'border-b-2 border-emerald-500 text-emerald-600 dark:text-emerald-400'
+ : 'text-gray-600 dark:text-gray-400',
+ 'shrink-0 px-3 sm:px-4 py-2 text-sm font-semibold transition',
+ ]"
+ @click="activeTab = tab.id"
+ >
+ {{ $t(tab.labelKey) }}
+ </button>
+ </div>
+ </div>
+
+ <div v-if="activeTab === 'status'" class="space-y-4">
+ <div class="grid gap-3 sm:grid-cols-2">
+ <div>
+ <label class="glass-label">{{ $t("rns_filesync.sync_directory") }}</label>
+ <input
+ v-model="syncDirectory"
+ type="text"
+ class="glass-input w-full font-mono text-sm"
+ :disabled="status.running"
+ :placeholder="$t('rns_filesync.sync_directory_placeholder')"
+ />
+ </div>
+ <div>
+ <label class="glass-label">{{ $t("rns_filesync.announce_interval") }}</label>
+ <input
+ v-model.number="announceInterval"
+ type="number"
+ min="10"
+ class="glass-input w-full"
+ />
+ </div>
+ </div>
+ <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
+ <input v-model="monitor" type="checkbox" class="rounded" />
+ {{ $t("rns_filesync.monitor") }}
+ </label>
+ <div class="flex flex-wrap gap-2">
+ <button
+ v-if="!status.running"
+ type="button"
+ class="primary-chip px-4 py-2 text-sm"
+ :disabled="busy"
+ @click="startService"
+ >
+ <MaterialDesignIcon icon-name="play" class="w-4 h-4" />
+ {{ $t("rns_filesync.start") }}
+ </button>
+ <button
+ v-else
+ type="button"
+ class="secondary-chip px-4 py-2 text-sm text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/50"
+ :disabled="busy"
+ @click="stopService"
+ >
+ <MaterialDesignIcon icon-name="stop" class="w-4 h-4" />
+ {{ $t("rns_filesync.stop") }}
+ </button>
+ <button
+ type="button"
+ class="secondary-chip px-4 py-2 text-sm"
+ :disabled="busy || !status.running"
+ @click="announceNow"
+ >
+ <MaterialDesignIcon icon-name="bullhorn" class="w-4 h-4" />
+ {{ $t("rns_filesync.announce") }}
+ </button>
+ <button
+ type="button"
+ class="secondary-chip px-4 py-2 text-sm"
+ :disabled="busy"
+ @click="refreshStatus"
+ >
+ <MaterialDesignIcon icon-name="refresh" class="w-4 h-4" />
+ {{ $t("rns_filesync.refresh") }}
+ </button>
+ </div>
+ <div class="space-y-2 text-sm">
+ <div class="flex flex-wrap gap-x-4 gap-y-1">
+ <span>
+ {{ $t("rns_filesync.running") }}:
+ <strong>{{ status.running ? $t("rns_filesync.yes") : $t("rns_filesync.no") }}</strong>
+ </span>
+ <span>
+ {{ $t("rns_filesync.peers_count") }}:
+ <strong>{{ status.peers || 0 }}</strong>
+ </span>
+ <span>
+ {{ $t("rns_filesync.files_count") }}:
+ <strong>{{ status.files || 0 }}</strong>
+ </span>
+ </div>
+ <div v-if="status.destination_hash" class="font-mono text-xs break-all">
+ <div class="font-semibold mb-1">{{ $t("rns_filesync.destination_hash") }}</div>
+ <button
+ type="button"
+ class="text-left hover:underline"
+ @click="copyHash(status.destination_hash)"
+ >
+ {{ status.destination_hash }}
+ </button>
+ </div>
+ <div v-if="lastProgress" class="text-xs text-gray-600 dark:text-gray-400">
+ {{ $t("rns_filesync.last_progress") }}: {{ lastProgress }}
+ </div>
+ </div>
+ </div>
+
+ <div v-else-if="activeTab === 'peers'" class="space-y-4">
+ <div class="flex flex-col sm:flex-row gap-2">
+ <input
+ v-model="connectHash"
+ type="text"
+ class="glass-input flex-1 font-mono text-sm"
+ :placeholder="$t('rns_filesync.peer_hash_placeholder')"
+ />
+ <button
+ type="button"
+ class="primary-chip px-4 py-2 text-sm"
+ :disabled="busy || !status.running"
+ @click="connectPeer"
+ >
+ {{ $t("rns_filesync.connect") }}
+ </button>
+ </div>
+ <button
+ type="button"
+ class="secondary-chip px-3 py-1.5 text-sm"
+ :disabled="busy"
+ @click="refreshPeers"
+ >
+ {{ $t("rns_filesync.refresh") }}
+ </button>
+ <div v-if="peers.length === 0" class="text-sm text-gray-500 dark:text-gray-400">
+ {{ $t("rns_filesync.no_peers") }}
+ </div>
+ <ul v-else class="space-y-2">
+ <li
+ v-for="peer in peers"
+ :key="peer.peer_id"
+ class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 p-3 rounded-lg border border-gray-200 dark:border-zinc-700"
+ >
+ <div class="min-w-0 font-mono text-xs break-all">
+ <div>{{ peer.peer_id }}</div>
+ <div class="text-gray-500 dark:text-gray-400">
+ {{ peer.destination_hash }} · {{ peer.status }}
+ </div>
+ </div>
+ <button
+ type="button"
+ class="secondary-chip px-3 py-1.5 text-sm text-red-600 dark:text-red-300"
+ :disabled="busy"
+ @click="disconnectPeer(peer.peer_id)"
+ >
+ {{ $t("rns_filesync.disconnect") }}
+ </button>
+ </li>
+ </ul>
+ </div>
+
+ <div v-else-if="activeTab === 'files'" class="space-y-4">
+ <button
+ type="button"
+ class="secondary-chip px-3 py-1.5 text-sm"
+ :disabled="busy"
+ @click="refreshFiles"
+ >
+ {{ $t("rns_filesync.refresh") }}
+ </button>
+ <div v-if="files.length === 0" class="text-sm text-gray-500 dark:text-gray-400">
+ {{ $t("rns_filesync.no_files") }}
+ </div>
+ <ul v-else class="space-y-2">
+ <li
+ v-for="file in files"
+ :key="file.path"
+ class="p-3 rounded-lg border border-gray-200 dark:border-zinc-700 font-mono text-xs"
+ >
+ <div class="break-all">{{ file.path }}</div>
+ <div class="text-gray-500 dark:text-gray-400 mt-1">
+ {{ file.size }} B · {{ file.hash }}
+ </div>
+ </li>
+ </ul>
+ </div>
+
+ <div v-else-if="activeTab === 'browse'" class="space-y-4">
+ <div class="flex flex-col sm:flex-row gap-2">
+ <select v-model="browsePeerId" class="glass-input flex-1 font-mono text-sm">
+ <option value="">{{ $t("rns_filesync.select_peer") }}</option>
+ <option v-for="peer in peers" :key="peer.peer_id" :value="peer.peer_id">
+ {{ peer.peer_id }}
+ </option>
+ </select>
+ <button
+ type="button"
+ class="primary-chip px-4 py-2 text-sm"
+ :disabled="busy || !status.running || !browsePeerId"
+ @click="browsePeer"
+ >
+ {{ $t("rns_filesync.browse") }}
+ </button>
+ </div>
+ <div v-if="remoteFiles.length === 0" class="text-sm text-gray-500 dark:text-gray-400">
+ {{ $t("rns_filesync.no_remote_files") }}
+ </div>
+ <ul v-else class="space-y-2">
+ <li
+ v-for="file in remoteFiles"
+ :key="file.path || file"
+ class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 p-3 rounded-lg border border-gray-200 dark:border-zinc-700"
+ >
+ <div class="min-w-0 font-mono text-xs break-all">
+ {{ file.path || file }}
+ <span v-if="file.size != null" class="text-gray-500"> · {{ file.size }} B</span>
+ </div>
+ <button
+ type="button"
+ class="secondary-chip px-3 py-1.5 text-sm"
+ :disabled="busy || !browsePeerId"
+ @click="downloadFile(file.path || file)"
+ >
+ {{ $t("rns_filesync.download") }}
+ </button>
+ </li>
+ </ul>
+ </div>
+
+ <div v-else-if="activeTab === 'acl'" class="space-y-4">
+ <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
+ <input v-model="aclEnforce" type="checkbox" class="rounded" @change="saveEnforce" />
+ {{ $t("rns_filesync.acl_enforce") }}
+ </label>
+ <div class="flex flex-col sm:flex-row gap-2">
+ <input
+ v-model="aclHash"
+ type="text"
+ class="glass-input flex-1 font-mono text-sm"
+ :placeholder="$t('rns_filesync.peer_hash_placeholder')"
+ />
+ <div class="flex items-center gap-3 text-sm">
+ <label class="flex items-center gap-1">
+ <input v-model="aclRead" type="checkbox" />
+ r
+ </label>
+ <label class="flex items-center gap-1">
+ <input v-model="aclWrite" type="checkbox" />
+ w
+ </label>
+ <label class="flex items-center gap-1">
+ <input v-model="aclDelete" type="checkbox" />
+ d
+ </label>
+ </div>
+ <button
+ type="button"
+ class="primary-chip px-4 py-2 text-sm"
+ :disabled="busy"
+ @click="grantAcl"
+ >
+ {{ $t("rns_filesync.acl_grant") }}
+ </button>
+ </div>
+ <pre
+ class="p-3 rounded-lg bg-zinc-100 dark:bg-zinc-900 text-xs overflow-x-auto"
+ >{{ aclRulesText }}</pre>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+import ToastUtils from "../../js/ToastUtils";
+import ToolsPageHeader from "../tools/ToolsPageHeader.vue";
+import { onWsEvent, offWsEvent } from "../../js/registries/wsEventRegistry.js";
+
+export default {
+ name: "RnsFilesyncPage",
+ components: {
+ MaterialDesignIcon,
+ ToolsPageHeader,
+ },
+ data() {
+ return {
+ activeTab: "status",
+ tabs: [
+ { id: "status", labelKey: "rns_filesync.tab_status" },
+ { id: "peers", labelKey: "rns_filesync.tab_peers" },
+ { id: "files", labelKey: "rns_filesync.tab_files" },
+ { id: "browse", labelKey: "rns_filesync.tab_browse" },
+ { id: "acl", labelKey: "rns_filesync.tab_acl" },
+ ],
+ busy: false,
+ status: {
+ running: false,
+ peers: 0,
+ files: 0,
+ destination_hash: null,
+ sync_directory: "",
+ },
+ syncDirectory: "",
+ announceInterval: 300,
+ monitor: true,
+ connectHash: "",
+ peers: [],
+ files: [],
+ browsePeerId: "",
+ remoteFiles: [],
+ aclEnforce: false,
+ aclHash: "",
+ aclRead: true,
+ aclWrite: false,
+ aclDelete: false,
+ aclRules: {},
+ lastProgress: "",
+ _wsHandlers: [],
+ };
+ },
+ computed: {
+ aclRulesText() {
+ try {
+ return JSON.stringify(this.aclRules || {}, null, 2);
+ } catch {
+ return "{}";
+ }
+ },
+ },
+ async mounted() {
+ this.bindWs();
+ await this.refreshAll();
+ },
+ beforeUnmount() {
+ for (const [type, handler] of this._wsHandlers) {
+ offWsEvent(type, handler);
+ }
+ this._wsHandlers = [];
+ },
+ methods: {
+ bindWs() {
+ const bind = (type, handler) => {
+ onWsEvent(type, handler);
+ this._wsHandlers.push([type, handler]);
+ };
+ bind("filesync.sync.progress", (payload) => {
+ this.lastProgress = JSON.stringify(payload);
+ });
+ bind("filesync.peer.connected", async () => {
+ await this.refreshPeers();
+ await this.refreshStatus();
+ });
+ bind("filesync.peer.disconnected", async () => {
+ await this.refreshPeers();
+ await this.refreshStatus();
+ });
+ bind("filesync.file.updated", async () => {
+ await this.refreshFiles();
+ ToastUtils.info(this.$t("rns_filesync.file_updated"));
+ });
+ bind("filesync.file.deleted", async () => {
+ await this.refreshFiles();
+ ToastUtils.info(this.$t("rns_filesync.file_deleted"));
+ });
+ bind("filesync.error", (payload) => {
+ const detail = payload?.error || payload?.message || "";
+ ToastUtils.error(
+ `${this.$t("rns_filesync.error")}${detail ? ": " + detail : ""}`,
+ );
+ });
+ },
+ async refreshAll() {
+ await Promise.all([
+ this.refreshStatus(),
+ this.refreshPeers(),
+ this.refreshFiles(),
+ this.refreshAcl(),
+ ]);
+ },
+ async refreshStatus() {
+ try {
+ const response = await window.api.get("/api/v1/filesync/status");
+ const status = response?.data || {};
+ this.status = status;
+ if (status.sync_directory) {
+ this.syncDirectory = status.sync_directory;
+ }
+ if (typeof status.announce_interval === "number") {
+ this.announceInterval = status.announce_interval;
+ }
+ if (typeof status.monitor === "boolean") {
+ this.monitor = status.monitor;
+ }
+ } catch (err) {
+ ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
+ }
+ },
+ async refreshPeers() {
+ try {
+ const response = await window.api.get("/api/v1/filesync/peers");
+ const data = response?.data || {};
+ this.peers = Array.isArray(data.peers) ? data.peers : [];
+ } catch (err) {
+ ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
+ }
+ },
+ async refreshFiles() {
+ try {
+ const response = await window.api.get("/api/v1/filesync/files");
+ const data = response?.data || {};
+ this.files = Array.isArray(data.files) ? data.files : [];
+ } catch (err) {
+ ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
+ }
+ },
+ async refreshAcl() {
+ try {
+ const response = await window.api.get("/api/v1/filesync/acl");
+ const data = response?.data || {};
+ this.aclEnforce = Boolean(data.enforce);
+ this.aclRules = data.rules || {};
+ } catch (err) {
+ ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
+ }
+ },
+ async startService() {
+ this.busy = true;
+ try {
+ if (!this.status.running && this.syncDirectory) {
+ await window.api.patch("/api/v1/filesync/settings", {
+ sync_directory: this.syncDirectory,
+ monitor: this.monitor,
+ announce_interval: this.announceInterval,
+ });
+ }
+ await window.api.post("/api/v1/filesync/start", {
+ sync_directory: this.syncDirectory || undefined,
+ monitor: this.monitor,
+ announce_interval: this.announceInterval,
+ });
+ ToastUtils.success(this.$t("rns_filesync.started"));
+ await this.refreshAll();
+ } catch (err) {
+ ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
+ } finally {
+ this.busy = false;
+ }
+ },
+ async stopService() {
+ this.busy = true;
+ try {
+ await window.api.post("/api/v1/filesync/stop", {});
+ ToastUtils.success(this.$t("rns_filesync.stopped"));
+ await this.refreshAll();
+ } catch (err) {
+ ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
+ } finally {
+ this.busy = false;
+ }
+ },
+ async announceNow() {
+ this.busy = true;
+ try {
+ await window.api.post("/api/v1/filesync/announce", {});
+ ToastUtils.success(this.$t("rns_filesync.announced"));
+ } catch (err) {
+ ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
+ } finally {
+ this.busy = false;
+ }
+ },
+ async connectPeer() {
+ this.busy = true;
+ try {
+ await window.api.post("/api/v1/filesync/connect", {
+ identity_hash: this.connectHash,
+ });
+ ToastUtils.success(this.$t("rns_filesync.connected"));
+ this.connectHash = "";
+ await this.refreshPeers();
+ await this.refreshStatus();
+ } catch (err) {
+ ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
+ } finally {
+ this.busy = false;
+ }
+ },
+ async disconnectPeer(peerId) {
+ this.busy = true;
+ try {
+ await window.api.post("/api/v1/filesync/disconnect", {
+ peer_id: peerId,
+ });
+ ToastUtils.success(this.$t("rns_filesync.disconnected"));
+ await this.refreshPeers();
+ await this.refreshStatus();
+ } catch (err) {
+ ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
+ } finally {
+ this.busy = false;
+ }
+ },
+ async browsePeer() {
+ this.busy = true;
+ try {
+ const response = await window.api.post("/api/v1/filesync/browse", {
+ peer_id: this.browsePeerId,
+ });
+ const data = response?.data || {};
+ this.remoteFiles = Array.isArray(data.files) ? data.files : [];
+ ToastUtils.success(this.$t("rns_filesync.browse_done"));
+ } catch (err) {
+ this.remoteFiles = [];
+ ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
+ } finally {
+ this.busy = false;
+ }
+ },
+ async downloadFile(path) {
+ this.busy = true;
+ try {
+ await window.api.post("/api/v1/filesync/download", {
+ peer_id: this.browsePeerId,
+ path,
+ });
+ ToastUtils.info(this.$t("rns_filesync.download_started"));
+ } catch (err) {
+ ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
+ } finally {
+ this.busy = false;
+ }
+ },
+ async grantAcl() {
+ const perms = [];
+ if (this.aclRead) perms.push("read");
+ if (this.aclWrite) perms.push("write");
+ if (this.aclDelete) perms.push("delete");
+ this.busy = true;
+ try {
+ await window.api.post("/api/v1/filesync/acl", {
+ identity_hash: this.aclHash,
+ perms,
+ enforce: this.aclEnforce,
+ });
+ ToastUtils.success(this.$t("rns_filesync.acl_updated"));
+ this.aclHash = "";
+ await this.refreshAcl();
+ } catch (err) {
+ ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
+ } finally {
+ this.busy = false;
+ }
+ },
+ async saveEnforce() {
+ this.busy = true;
+ try {
+ await window.api.post("/api/v1/filesync/acl", {
+ enforce: this.aclEnforce,
+ });
+ ToastUtils.success(this.$t("rns_filesync.acl_updated"));
+ await this.refreshAcl();
+ } catch (err) {
+ ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
+ } finally {
+ this.busy = false;
+ }
+ },
+ async copyHash(hash) {
+ try {
+ await navigator.clipboard.writeText(hash);
+ ToastUtils.success(this.$t("rns_filesync.copied"));
+ } catch {
+ ToastUtils.error(this.$t("rns_filesync.error"));
+ }
+ },
+ },
+};
+</script>

diff --git a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
index cbd4576d..7205ca08 100644
--- a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
@@ -40,6 +40,7 @@
:online-interface-count="onlineInterfaces.length"
:offline-interface-count="offlineInterfaces.length"
:search-query="searchQuery"
+ :preferred-renderer="preferredRenderer"
:engine-mode="engineMode"
:fps="fps"
@update:is-showing-controls="isShowingControls = $event"
@@ -47,6 +48,7 @@
@update:enable-physics="enablePhysics = $event"
@update:hop-max-filter="onUserHopMaxFilterChange"
@update:search-query="searchQuery = $event"
+ @update:preferred-renderer="onPreferredRendererChange"
@manual-update="manualUpdate"
/>
<NetworkVisualiserLegend
@@ -93,6 +95,7 @@ import {
loadVisualiserDisplayPrefs,
persistVisualiserAutoReload,
persistVisualiserLiveLayout,
+ persistVisualiserRenderer,
VISUALISER_DISPLAY_PREFS_CHANGED,
} from "../../js/settings/settingsVisualiserPrefs.js";
import ToastUtils from "../../js/ToastUtils";
@@ -821,6 +824,13 @@ export default {
this.autoReload = p.autoReload;
this.preferredRenderer = p.renderer || "auto";
},
+ async onPreferredRendererChange(next) {
+ const normalized = next === "webgl" || next === "vis" || next === "auto" ? next : "auto";
+ if (normalized === this.preferredRenderer) return;
+ this.preferredRenderer = normalized;
+ persistVisualiserRenderer(normalized, { emit: false });
+ await this.reinitRenderer();
+ },
destroyActiveRenderer() {
this.hoverTooltip = null;
if (this.webglEngine) {
@@ -926,7 +936,7 @@ export default {
const layoutEdges = this.edges.get().map((e) => ({
from: e.from,
to: e.to,
- length: e.width >= 2.5 ? 150 : 180,
+ length: e.width >= 2.5 ? 260 : 300,
}));
const settled = settleLayout({ nodes: layoutNodes, edges: layoutEdges, iterations: 0 });
const positions = settled?.positions || {};
@@ -1642,7 +1652,7 @@ export default {
graph.layout_edges = graphEdges.map((e) => ({
from: e.from,
to: e.to,
- length: e.width >= 2.5 ? 150 : 180,
+ length: e.width >= 2.5 ? 260 : 300,
}));
}

diff --git a/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue
index f6573bd1..f0a0cb98 100644
--- a/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue
@@ -53,16 +53,26 @@
<div class="grid grid-cols-2 gap-2">
<div
class="rounded-xl px-3 py-2 border border-gray-100 dark:border-zinc-700/50 bg-gray-50/60 dark:bg-zinc-800/40"
- :title="engineModeTitle"
+ :title="engineSelectTitle"
>
- <div
- class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-0.5"
+ <label
+ for="visualiser-engine-select"
+ class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-0.5 block"
>
{{ $t("visualiser.engine") }}
- </div>
- <div class="text-xs font-bold truncate" :class="engineModeClass">
- {{ engineModeLabel }}
- </div>
+ </label>
+ <select
+ id="visualiser-engine-select"
+ class="w-full bg-transparent text-xs font-bold truncate border-0 p-0 pr-5 focus:outline-hidden focus:ring-0 cursor-pointer"
+ :class="engineSelectClass"
+ :value="preferredRenderer"
+ :aria-label="$t('visualiser.engine')"
+ @change="onEngineSelectChange"
+ >
+ <option value="auto">{{ $t("visualiser.renderer_option_auto") }}</option>
+ <option value="webgl">{{ $t("visualiser.renderer_option_webgl") }}</option>
+ <option value="vis">{{ $t("visualiser.renderer_option_vis") }}</option>
+ </select>
</div>
<div
class="rounded-xl px-3 py-2 border border-gray-100 dark:border-zinc-700/50 bg-gray-50/60 dark:bg-zinc-800/40"
@@ -239,11 +249,18 @@ export default {
onlineInterfaceCount: { type: Number, default: 0 },
offlineInterfaceCount: { type: Number, default: 0 },
searchQuery: { type: String, default: "" },
+ preferredRenderer: {
+ type: String,
+ default: "auto",
+ validator(v) {
+ return ["auto", "webgl", "vis"].includes(v);
+ },
+ },
engineMode: {
type: String,
default: "checking",
validator(v) {
- return ["checking", "wasm", "fallback"].includes(v);
+ return ["checking", "wasm", "fallback", "webgl"].includes(v);
},
},
fps: { type: Number, default: 0 },
@@ -254,6 +271,7 @@ export default {
"update:enablePhysics",
"update:hopMaxFilter",
"update:searchQuery",
+ "update:preferredRenderer",
"manual-update",
],
data() {
@@ -277,23 +295,17 @@ export default {
if (this.hopMaxFilter === null) return this.$t("visualiser.all");
return String(this.hopMaxFilter);
},
- engineModeLabel() {
- if (this.engineMode === "webgl") return this.$t("visualiser.engine_webgl");
- if (this.engineMode === "wasm") return this.$t("visualiser.engine_wasm");
- if (this.engineMode === "fallback") return this.$t("visualiser.engine_fallback");
- return this.$t("visualiser.engine_checking");
- },
- engineModeTitle() {
+ engineSelectTitle() {
if (this.engineMode === "webgl") return this.$t("visualiser.engine_webgl_hint");
if (this.engineMode === "wasm") return this.$t("visualiser.engine_wasm_hint");
if (this.engineMode === "fallback") return this.$t("visualiser.engine_fallback_hint");
- return this.$t("visualiser.engine_checking_hint");
+ return this.$t("visualiser.renderer_desc");
},
- engineModeClass() {
+ engineSelectClass() {
if (this.engineMode === "webgl") return "text-sky-600 dark:text-sky-400";
if (this.engineMode === "wasm") return "text-emerald-600 dark:text-emerald-400";
if (this.engineMode === "fallback") return "text-amber-600 dark:text-amber-400";
- return "text-gray-500 dark:text-zinc-400";
+ return "text-gray-800 dark:text-zinc-100";
},
fpsDisplay() {
const n = Number(this.fps);
@@ -302,6 +314,12 @@ export default {
},
},
methods: {
+ onEngineSelectChange(e) {
+ const next = e?.target?.value;
+ if (next === "auto" || next === "webgl" || next === "vis") {
+ this.$emit("update:preferredRenderer", next);
+ }
+ },
onHopSliderInput(e) {
const v = hopSliderPosToMaxHops(Number(e.target.value));
this.$emit("update:hopMaxFilter", v);

diff --git a/meshchatx/src/frontend/js/networkVisualiserPerf.js b/meshchatx/src/frontend/js/networkVisualiserPerf.js
index 3c3ee79a..9ef0ecdc 100644
--- a/meshchatx/src/frontend/js/networkVisualiserPerf.js
+++ b/meshchatx/src/frontend/js/networkVisualiserPerf.js
@@ -381,7 +381,7 @@ export function buildFullGraph(req) {
layout_edges: (path.edges || []).map((e) => ({
from: e.from,
to: e.to,
- length: e.width >= 2 ? 150 : 180,
+ length: e.width >= 2 ? 260 : 300,
})),
};
}

diff --git a/meshchatx/src/frontend/js/registries/coreCommandEntries.js b/meshchatx/src/frontend/js/registries/coreCommandEntries.js
index 17fec062..c32bf781 100644
--- a/meshchatx/src/frontend/js/registries/coreCommandEntries.js
+++ b/meshchatx/src/frontend/js/registries/coreCommandEntries.js
@@ -86,6 +86,14 @@ export const CORE_COMMAND_ENTRIES = [
type: "navigation",
route: { name: "rncp" },
},
+ {
+ id: "nav-rns-filesync",
+ title: "nav_rns_filesync",
+ description: "nav_rns_filesync_desc",
+ icon: "folder-sync",
+ type: "navigation",
+ route: { name: "rns-filesync" },
+ },
{
id: "nav-rnsh",
title: "nav_rnsh",

diff --git a/meshchatx/src/frontend/js/registries/coreToolsEntries.js b/meshchatx/src/frontend/js/registries/coreToolsEntries.js
index 15a5a4ae..a11b246a 100644
--- a/meshchatx/src/frontend/js/registries/coreToolsEntries.js
+++ b/meshchatx/src/frontend/js/registries/coreToolsEntries.js
@@ -212,7 +212,7 @@ export const CORE_TOOLS_ENTRIES = [
},
{
name: "rns-filesync",
- comingSoon: true,
+ route: { name: "rns-filesync" },
icon: "folder-sync",
iconBg: "tool-card__icon bg-emerald-50 text-emerald-500 dark:bg-emerald-900/30 dark:text-emerald-200",
titleKey: "tools.rns_filesync.title",

diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 5abe2c6b..862918b5 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -2601,8 +2601,8 @@
"description": "IP-Tunneling über Reticulum-Verbindungen (in Entwicklung)."
},
"rns_filesync": {
- "title": "RNSFilesync",
- "description": "Dateisync zwischen Mesh-Peers (in Entwicklung)."
+ "title": "RNS FileSync",
+ "description": "Sync a directory with mesh peers over Reticulum using destination hashes."
},
"bots": {
"title": "LXMFy-Bots",
@@ -3249,7 +3249,9 @@
"nav_rnsh": "RNSH",
"nav_rnsh_desc": "Remote-Shell über Reticulum",
"nav_rnx": "RNX",
- "nav_rnx_desc": "Remote-Befehlsausführung über Reticulum"
+ "nav_rnx_desc": "Remote-Befehlsausführung über Reticulum",
+ "nav_rns_filesync": "RNS FileSync",
+ "nav_rns_filesync_desc": "Directory sync over Reticulum"
},
"settings": {
"shortcut_saved": "Verknüpfung gespeichert",
@@ -3792,5 +3794,57 @@
"windows_screen_security_desc": "MeshChatX kann den Windows-Inhaltsschutz nutzen (dasselbe DRM-ähnliche Display-Flag wie Medien-Apps), damit Screenshots, Recorder und Windows Recall das App-Fenster nicht erfassen. Später änderbar unter Einstellungen → Datenschutz.",
"windows_screen_security_enable": "Bildschirmsicherheit aktivieren",
"windows_screen_security_later": "Nicht jetzt"
+ },
+ "rns_filesync": {
+ "eyebrow": "Directory sync",
+ "title": "RNS FileSync",
+ "description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
+ "usage_steps": "How to use FileSync:",
+ "step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
+ "step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
+ "step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
+ "tab_status": "Status",
+ "tab_peers": "Peers",
+ "tab_files": "Files",
+ "tab_browse": "Browse",
+ "tab_acl": "ACL",
+ "sync_directory": "Sync directory",
+ "sync_directory_placeholder": "Path under this identity storage",
+ "announce_interval": "Announce interval (seconds)",
+ "monitor": "Watch directory for changes",
+ "start": "Start",
+ "stop": "Stop",
+ "announce": "Announce now",
+ "refresh": "Refresh",
+ "running": "Running",
+ "yes": "Yes",
+ "no": "No",
+ "peers_count": "Peers",
+ "files_count": "Files",
+ "destination_hash": "Destination hash",
+ "last_progress": "Last progress",
+ "peer_hash_placeholder": "Identity hash (hex)",
+ "connect": "Connect",
+ "disconnect": "Disconnect",
+ "no_peers": "No connected peers yet.",
+ "no_files": "No local files in the sync inventory yet.",
+ "select_peer": "Select a peer",
+ "browse": "Browse",
+ "no_remote_files": "No remote files listed. Connect and browse a peer.",
+ "download": "Download",
+ "acl_enforce": "Enforce ACL (deny by default)",
+ "acl_grant": "Grant",
+ "acl_updated": "ACL updated",
+ "started": "FileSync started",
+ "stopped": "FileSync stopped",
+ "announced": "Announce sent",
+ "connected": "Peer connect requested",
+ "disconnected": "Peer disconnected",
+ "browse_done": "Browse complete",
+ "download_started": "Download requested",
+ "file_updated": "File updated",
+ "file_deleted": "File deleted",
+ "copied": "Copied to clipboard",
+ "error": "FileSync error"
}
}

diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index e60f2eac..46b8ee00 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -2798,8 +2798,8 @@
"description": "Carry IP traffic over Reticulum links (in development)."
},
"rns_filesync": {
- "title": "RNS Filesync",
- "description": "Sync files between mesh peers (in development)."
+ "title": "RNS FileSync",
+ "description": "Sync a directory with mesh peers over Reticulum using destination hashes."
},
"bots": {
"title": "LXMFy Bots",
@@ -3025,6 +3025,7 @@
"step_2": "2. To **send** a file, get the **Destination Hash** of a listening node, enter the **Local File Path**, and click **Send File**.",
"step_3": "3. To **fetch** a file, get the **Destination Hash** of a node listening with 'Allow Fetch' enabled, enter the **Remote File Path**, and click **Fetch File**.",
"send_file": "Send File",
+
"fetch_file": "Fetch File",
"listen": "Listen",
"destination_hash": "Destination Hash",
@@ -3067,6 +3068,58 @@
"web_path_hint": "Browsers cannot expose full file paths. Use the desktop app to browse, or paste the full path to the file on this machine.",
"web_save_path_prompt": "Enter the absolute folder path on the machine running MeshChat (server) where the fetched file should be saved:"
},
+ "rns_filesync": {
+ "eyebrow": "Directory sync",
+ "title": "RNS FileSync",
+ "description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
+ "usage_steps": "How to use FileSync:",
+ "step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
+ "step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
+ "step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
+ "tab_status": "Status",
+ "tab_peers": "Peers",
+ "tab_files": "Files",
+ "tab_browse": "Browse",
+ "tab_acl": "ACL",
+ "sync_directory": "Sync directory",
+ "sync_directory_placeholder": "Path under this identity storage",
+ "announce_interval": "Announce interval (seconds)",
+ "monitor": "Watch directory for changes",
+ "start": "Start",
+ "stop": "Stop",
+ "announce": "Announce now",
+ "refresh": "Refresh",
+ "running": "Running",
+ "yes": "Yes",
+ "no": "No",
+ "peers_count": "Peers",
+ "files_count": "Files",
+ "destination_hash": "Destination hash",
+ "last_progress": "Last progress",
+ "peer_hash_placeholder": "Identity hash (hex)",
+ "connect": "Connect",
+ "disconnect": "Disconnect",
+ "no_peers": "No connected peers yet.",
+ "no_files": "No local files in the sync inventory yet.",
+ "select_peer": "Select a peer",
+ "browse": "Browse",
+ "no_remote_files": "No remote files listed. Connect and browse a peer.",
+ "download": "Download",
+ "acl_enforce": "Enforce ACL (deny by default)",
+ "acl_grant": "Grant",
+ "acl_updated": "ACL updated",
+ "started": "FileSync started",
+ "stopped": "FileSync stopped",
+ "announced": "Announce sent",
+ "connected": "Peer connect requested",
+ "disconnected": "Peer disconnected",
+ "browse_done": "Browse complete",
+ "download_started": "Download requested",
+ "file_updated": "File updated",
+ "file_deleted": "File deleted",
+ "copied": "Copied to clipboard",
+ "error": "FileSync error"
+ },
"rnsh": {
"remote_shell": "Remote shell",
"usage_title": "Usage",
@@ -3752,6 +3805,8 @@
"nav_rnprobe_desc": "Probe reachability and delay",
"nav_rncp": "RNCP",
"nav_rncp_desc": "File copy over Reticulum (RNCP)",
+ "nav_rns_filesync": "RNS FileSync",
+ "nav_rns_filesync_desc": "Directory sync over Reticulum",
"nav_rnsh": "RNSH",
"nav_rnsh_desc": "Remote shell over Reticulum",
"nav_rnx": "RNX",

diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index 625aab09..daa4098c 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -2794,8 +2794,8 @@
"description": "Tunelización IP sobre los enlaces de Reticulum (en desarrollo)."
},
"rns_filesync": {
- "title": "RNS Filesync",
- "description": "Sincronización de archivos entre pares de malla (en desarrollo)."
+ "title": "RNS FileSync",
+ "description": "Sync a directory with mesh peers over Reticulum using destination hashes."
},
"bots": {
"title": "Bots LXMFy",
@@ -3494,7 +3494,9 @@
"nav_rnsh": "RNSH",
"nav_rnsh_desc": "Shell remoto sobre Reticulum",
"nav_rnx": "RNX",
- "nav_rnx_desc": "Ejecución remota de comandos sobre Reticulum"
+ "nav_rnx_desc": "Ejecución remota de comandos sobre Reticulum",
+ "nav_rns_filesync": "RNS FileSync",
+ "nav_rns_filesync_desc": "Directory sync over Reticulum"
},
"relay_chat": {
"title": "Chat de retransmision",
@@ -3792,5 +3794,57 @@
"windows_screen_security_desc": "MeshChatX puede usar la protección de contenido de Windows (la misma marca tipo DRM que usan las apps de medios) para que capturas, grabadoras y Windows Recall no capturen la ventana. Puede cambiarlo después en Ajustes → Privacidad.",
"windows_screen_security_enable": "Activar seguridad de pantalla",
"windows_screen_security_later": "Ahora no"
+ },
+ "rns_filesync": {
+ "eyebrow": "Directory sync",
+ "title": "RNS FileSync",
+ "description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
+ "usage_steps": "How to use FileSync:",
+ "step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
+ "step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
+ "step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
+ "tab_status": "Status",
+ "tab_peers": "Peers",
+ "tab_files": "Files",
+ "tab_browse": "Browse",
+ "tab_acl": "ACL",
+ "sync_directory": "Sync directory",
+ "sync_directory_placeholder": "Path under this identity storage",
+ "announce_interval": "Announce interval (seconds)",
+ "monitor": "Watch directory for changes",
+ "start": "Start",
+ "stop": "Stop",
+ "announce": "Announce now",
+ "refresh": "Refresh",
+ "running": "Running",
+ "yes": "Yes",
+ "no": "No",
+ "peers_count": "Peers",
+ "files_count": "Files",
+ "destination_hash": "Destination hash",
+ "last_progress": "Last progress",
+ "peer_hash_placeholder": "Identity hash (hex)",
+ "connect": "Connect",
+ "disconnect": "Disconnect",
+ "no_peers": "No connected peers yet.",
+ "no_files": "No local files in the sync inventory yet.",
+ "select_peer": "Select a peer",
+ "browse": "Browse",
+ "no_remote_files": "No remote files listed. Connect and browse a peer.",
+ "download": "Download",
+ "acl_enforce": "Enforce ACL (deny by default)",
+ "acl_grant": "Grant",
+ "acl_updated": "ACL updated",
+ "started": "FileSync started",
+ "stopped": "FileSync stopped",
+ "announced": "Announce sent",
+ "connected": "Peer connect requested",
+ "disconnected": "Peer disconnected",
+ "browse_done": "Browse complete",
+ "download_started": "Download requested",
+ "file_updated": "File updated",
+ "file_deleted": "File deleted",
+ "copied": "Copied to clipboard",
+ "error": "FileSync error"
}
}

diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index ba0f3856..5a01519c 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -2794,8 +2794,8 @@
"description": "Kuljeta IP-liikennettä Reticulum-linkkien yli (kehitysvaiheessa)."
},
"rns_filesync": {
- "title": "RNS Filesync",
- "description": "Synkronoi tiedostoja verkon vertaisten välillä (kehitysvaiheessa)."
+ "title": "RNS FileSync",
+ "description": "Sync a directory with mesh peers over Reticulum using destination hashes."
},
"bots": {
"title": "LXMFy-botit",
@@ -3701,7 +3701,9 @@
"nav_rnsh": "RNSH",
"nav_rnsh_desc": "Etäkuori Reticulumin yli",
"nav_rnx": "RNX",
- "nav_rnx_desc": "Etäkomentojen suoritus Reticulumin yli"
+ "nav_rnx_desc": "Etäkomentojen suoritus Reticulumin yli",
+ "nav_rns_filesync": "RNS FileSync",
+ "nav_rns_filesync_desc": "Directory sync over Reticulum"
},
"rnx": {
"title": "RNX - Reticulum Remote Execution",
@@ -3792,5 +3794,57 @@
"windows_screen_security_desc": "MeshChatX voi käyttää Windowsin sisällönsuojausta (samaa DRM-tyylistä näyttölippua kuin mediaohjelmat), jotta kuvakaappaukset, tallentimet ja Windows Recall eivät kaappaa sovellusikkunaa. Voit muuttaa tätä myöhemmin kohdassa Asetukset → Tietosuoja.",
"windows_screen_security_enable": "Ota näytön suojaus käyttöön",
"windows_screen_security_later": "Ei nyt"
+ },
+ "rns_filesync": {
+ "eyebrow": "Directory sync",
+ "title": "RNS FileSync",
+ "description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
+ "usage_steps": "How to use FileSync:",
+ "step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
+ "step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
+ "step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
+ "tab_status": "Status",
+ "tab_peers": "Peers",
+ "tab_files": "Files",
+ "tab_browse": "Browse",
+ "tab_acl": "ACL",
+ "sync_directory": "Sync directory",
+ "sync_directory_placeholder": "Path under this identity storage",
+ "announce_interval": "Announce interval (seconds)",
+ "monitor": "Watch directory for changes",
+ "start": "Start",
+ "stop": "Stop",
+ "announce": "Announce now",
+ "refresh": "Refresh",
+ "running": "Running",
+ "yes": "Yes",
+ "no": "No",
+ "peers_count": "Peers",
+ "files_count": "Files",
+ "destination_hash": "Destination hash",
+ "last_progress": "Last progress",
+ "peer_hash_placeholder": "Identity hash (hex)",
+ "connect": "Connect",
+ "disconnect": "Disconnect",
+ "no_peers": "No connected peers yet.",
+ "no_files": "No local files in the sync inventory yet.",
+ "select_peer": "Select a peer",
+ "browse": "Browse",
+ "no_remote_files": "No remote files listed. Connect and browse a peer.",
+ "download": "Download",
+ "acl_enforce": "Enforce ACL (deny by default)",
+ "acl_grant": "Grant",
+ "acl_updated": "ACL updated",
+ "started": "FileSync started",
+ "stopped": "FileSync stopped",
+ "announced": "Announce sent",
+ "connected": "Peer connect requested",
+ "disconnected": "Peer disconnected",
+ "browse_done": "Browse complete",
+ "download_started": "Download requested",
+ "file_updated": "File updated",
+ "file_deleted": "File deleted",
+ "copied": "Copied to clipboard",
+ "error": "FileSync error"
}
}

diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 3fcd76c0..14259720 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -2794,8 +2794,8 @@
"description": "Tunnel IP sur les liaisons Reticulum (en développement)."
},
"rns_filesync": {
- "title": "Fichiers RNSync",
- "description": "Synchronisation des fichiers entre les paires de mailles (en développement)."
+ "title": "RNS FileSync",
+ "description": "Sync a directory with mesh peers over Reticulum using destination hashes."
},
"bots": {
"title": "Bots LXMFy",
@@ -3494,7 +3494,9 @@
"nav_rnsh": "RNSH",
"nav_rnsh_desc": "Shell distant sur Reticulum",
"nav_rnx": "RNX",
- "nav_rnx_desc": "Exécution distante de commandes sur Reticulum"
+ "nav_rnx_desc": "Exécution distante de commandes sur Reticulum",
+ "nav_rns_filesync": "RNS FileSync",
+ "nav_rns_filesync_desc": "Directory sync over Reticulum"
},
"relay_chat": {
"title": "Chat relais",
@@ -3792,5 +3794,57 @@
"windows_screen_security_desc": "MeshChatX peut utiliser la protection de contenu Windows (le même indicateur d’affichage de type DRM que les apps multimédias) pour que les captures, enregistreurs et Windows Recall ne capturent pas la fenêtre. Modifiable plus tard dans Paramètres → Confidentialité.",
"windows_screen_security_enable": "Activer la sécurité d’écran",
"windows_screen_security_later": "Pas maintenant"
+ },
+ "rns_filesync": {
+ "eyebrow": "Directory sync",
+ "title": "RNS FileSync",
+ "description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
+ "usage_steps": "How to use FileSync:",
+ "step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
+ "step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
+ "step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
+ "tab_status": "Status",
+ "tab_peers": "Peers",
+ "tab_files": "Files",
+ "tab_browse": "Browse",
+ "tab_acl": "ACL",
+ "sync_directory": "Sync directory",
+ "sync_directory_placeholder": "Path under this identity storage",
+ "announce_interval": "Announce interval (seconds)",
+ "monitor": "Watch directory for changes",
+ "start": "Start",
+ "stop": "Stop",
+ "announce": "Announce now",
+ "refresh": "Refresh",
+ "running": "Running",
+ "yes": "Yes",
+ "no": "No",
+ "peers_count": "Peers",
+ "files_count": "Files",
+ "destination_hash": "Destination hash",
+ "last_progress": "Last progress",
+ "peer_hash_placeholder": "Identity hash (hex)",
+ "connect": "Connect",
+ "disconnect": "Disconnect",
+ "no_peers": "No connected peers yet.",
+ "no_files": "No local files in the sync inventory yet.",
+ "select_peer": "Select a peer",
+ "browse": "Browse",
+ "no_remote_files": "No remote files listed. Connect and browse a peer.",
+ "download": "Download",
+ "acl_enforce": "Enforce ACL (deny by default)",
+ "acl_grant": "Grant",
+ "acl_updated": "ACL updated",
+ "started": "FileSync started",
+ "stopped": "FileSync stopped",
+ "announced": "Announce sent",
+ "connected": "Peer connect requested",
+ "disconnected": "Peer disconnected",
+ "browse_done": "Browse complete",
+ "download_started": "Download requested",
+ "file_updated": "File updated",
+ "file_deleted": "File deleted",
+ "copied": "Copied to clipboard",
+ "error": "FileSync error"
}
}

diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index fd0ee691..ab2ee9aa 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -2846,8 +2846,8 @@
"description": "Tunnel IP su collegamenti Reticulum (in sviluppo)."
},
"rns_filesync": {
- "title": "RNS Filesync",
- "description": "Sincronizzazione file tra peer mesh (in sviluppo)."
+ "title": "RNS FileSync",
+ "description": "Sync a directory with mesh peers over Reticulum using destination hashes."
},
"bots": {
"title": "LXMFy Bot",
@@ -3494,7 +3494,9 @@
"nav_rnsh": "RNSH",
"nav_rnsh_desc": "Shell remota su Reticulum",
"nav_rnx": "RNX",
- "nav_rnx_desc": "Esecuzione remota di comandi su Reticulum"
+ "nav_rnx_desc": "Esecuzione remota di comandi su Reticulum",
+ "nav_rns_filesync": "RNS FileSync",
+ "nav_rns_filesync_desc": "Directory sync over Reticulum"
},
"relay_chat": {
"title": "Chat relay",
@@ -3792,5 +3794,57 @@
"windows_screen_security_desc": "MeshChatX può usare la protezione contenuti di Windows (lo stesso flag display in stile DRM delle app multimediali) così screenshot, registratori e Windows Recall non catturano la finestra. Modificabile in seguito in Impostazioni → Privacy.",
"windows_screen_security_enable": "Attiva sicurezza schermo",
"windows_screen_security_later": "Non ora"
+ },
+ "rns_filesync": {
+ "eyebrow": "Directory sync",
+ "title": "RNS FileSync",
+ "description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
+ "usage_steps": "How to use FileSync:",
+ "step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
+ "step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
+ "step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
+ "tab_status": "Status",
+ "tab_peers": "Peers",
+ "tab_files": "Files",
+ "tab_browse": "Browse",
+ "tab_acl": "ACL",
+ "sync_directory": "Sync directory",
+ "sync_directory_placeholder": "Path under this identity storage",
+ "announce_interval": "Announce interval (seconds)",
+ "monitor": "Watch directory for changes",
+ "start": "Start",
+ "stop": "Stop",
+ "announce": "Announce now",
+ "refresh": "Refresh",
+ "running": "Running",
+ "yes": "Yes",
+ "no": "No",
+ "peers_count": "Peers",
+ "files_count": "Files",
+ "destination_hash": "Destination hash",
+ "last_progress": "Last progress",
+ "peer_hash_placeholder": "Identity hash (hex)",
+ "connect": "Connect",
+ "disconnect": "Disconnect",
+ "no_peers": "No connected peers yet.",
+ "no_files": "No local files in the sync inventory yet.",
+ "select_peer": "Select a peer",
+ "browse": "Browse",
+ "no_remote_files": "No remote files listed. Connect and browse a peer.",
+ "download": "Download",
+ "acl_enforce": "Enforce ACL (deny by default)",
+ "acl_grant": "Grant",
+ "acl_updated": "ACL updated",
+ "started": "FileSync started",
+ "stopped": "FileSync stopped",
+ "announced": "Announce sent",
+ "connected": "Peer connect requested",
+ "disconnected": "Peer disconnected",
+ "browse_done": "Browse complete",
+ "download_started": "Download requested",
+ "file_updated": "File updated",
+ "file_deleted": "File deleted",
+ "copied": "Copied to clipboard",
+ "error": "FileSync error"
}
}

diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index dfa57852..40d844be 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -2794,8 +2794,8 @@
"description": "IP tunneling over Reticulum links (in ontwikkeling)."
},
"rns_filesync": {
- "title": "RNS Filesync",
- "description": "Bestand synchroniseren tussen mesh peers (in ontwikkeling)."
+ "title": "RNS FileSync",
+ "description": "Sync a directory with mesh peers over Reticulum using destination hashes."
},
"bots": {
"title": "LXMFy Bots",
@@ -3494,7 +3494,9 @@
"nav_rnsh": "RNSH",
"nav_rnsh_desc": "Remote shell over Reticulum",
"nav_rnx": "RNX",
- "nav_rnx_desc": "Externe opdrachtuitvoering over Reticulum"
+ "nav_rnx_desc": "Externe opdrachtuitvoering over Reticulum",
+ "nav_rns_filesync": "RNS FileSync",
+ "nav_rns_filesync_desc": "Directory sync over Reticulum"
},
"relay_chat": {
"title": "Relaychat",
@@ -3792,5 +3794,57 @@
"windows_screen_security_desc": "MeshChatX kan Windows-inhoudsbescherming gebruiken (dezelfde DRM-achtige displayvlag als media-apps) zodat screenshots, recorders en Windows Recall het appvenster niet vastleggen. Later te wijzigen onder Instellingen → Privacy.",
"windows_screen_security_enable": "Schermbeveiliging inschakelen",
"windows_screen_security_later": "Niet nu"
+ },
+ "rns_filesync": {
+ "eyebrow": "Directory sync",
+ "title": "RNS FileSync",
+ "description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
+ "usage_steps": "How to use FileSync:",
+ "step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
+ "step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
+ "step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
+ "tab_status": "Status",
+ "tab_peers": "Peers",
+ "tab_files": "Files",
+ "tab_browse": "Browse",
+ "tab_acl": "ACL",
+ "sync_directory": "Sync directory",
+ "sync_directory_placeholder": "Path under this identity storage",
+ "announce_interval": "Announce interval (seconds)",
+ "monitor": "Watch directory for changes",
+ "start": "Start",
+ "stop": "Stop",
+ "announce": "Announce now",
+ "refresh": "Refresh",
+ "running": "Running",
+ "yes": "Yes",
+ "no": "No",
+ "peers_count": "Peers",
+ "files_count": "Files",
+ "destination_hash": "Destination hash",
+ "last_progress": "Last progress",
+ "peer_hash_placeholder": "Identity hash (hex)",
+ "connect": "Connect",
+ "disconnect": "Disconnect",
+ "no_peers": "No connected peers yet.",
+ "no_files": "No local files in the sync inventory yet.",
+ "select_peer": "Select a peer",
+ "browse": "Browse",
+ "no_remote_files": "No remote files listed. Connect and browse a peer.",
+ "download": "Download",
+ "acl_enforce": "Enforce ACL (deny by default)",
+ "acl_grant": "Grant",
+ "acl_updated": "ACL updated",
+ "started": "FileSync started",
+ "stopped": "FileSync stopped",
+ "announced": "Announce sent",
+ "connected": "Peer connect requested",
+ "disconnected": "Peer disconnected",
+ "browse_done": "Browse complete",
+ "download_started": "Download requested",
+ "file_updated": "File updated",
+ "file_deleted": "File deleted",
+ "copied": "Copied to clipboard",
+ "error": "FileSync error"
}
}

diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 5ff34525..ff330a2a 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -2601,8 +2601,8 @@
"description": "IP-туннелирование по каналам Reticulum (в разработке)."
},
"rns_filesync": {
- "title": "RNS Filesync",
- "description": "Синхронизация файлов между узлами mesh (в разработке)."
+ "title": "RNS FileSync",
+ "description": "Sync a directory with mesh peers over Reticulum using destination hashes."
},
"bots": {
"title": "LXMFy Боты",
@@ -3249,7 +3249,9 @@
"nav_rnsh": "RNSH",
"nav_rnsh_desc": "Удалённая оболочка через Reticulum",
"nav_rnx": "RNX",
- "nav_rnx_desc": "Удалённое выполнение команд через Reticulum"
+ "nav_rnx_desc": "Удалённое выполнение команд через Reticulum",
+ "nav_rns_filesync": "RNS FileSync",
+ "nav_rns_filesync_desc": "Directory sync over Reticulum"
},
"settings": {
"shortcut_saved": "Ярлык сохранён",
@@ -3792,5 +3794,57 @@
"windows_screen_security_desc": "MeshChatX может использовать защиту содержимого Windows (тот же DRM-подобный флаг отображения, что у медиаприложений), чтобы снимки, запись и Windows Recall не захватывали окно. Позже можно изменить в Настройки → Конфиденциальность.",
"windows_screen_security_enable": "Включить защиту экрана",
"windows_screen_security_later": "Не сейчас"
+ },
+ "rns_filesync": {
+ "eyebrow": "Directory sync",
+ "title": "RNS FileSync",
+ "description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
+ "usage_steps": "How to use FileSync:",
+ "step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
+ "step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
+ "step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
+ "tab_status": "Status",
+ "tab_peers": "Peers",
+ "tab_files": "Files",
+ "tab_browse": "Browse",
+ "tab_acl": "ACL",
+ "sync_directory": "Sync directory",
+ "sync_directory_placeholder": "Path under this identity storage",
+ "announce_interval": "Announce interval (seconds)",
+ "monitor": "Watch directory for changes",
+ "start": "Start",
+ "stop": "Stop",
+ "announce": "Announce now",
+ "refresh": "Refresh",
+ "running": "Running",
+ "yes": "Yes",
+ "no": "No",
+ "peers_count": "Peers",
+ "files_count": "Files",
+ "destination_hash": "Destination hash",
+ "last_progress": "Last progress",
+ "peer_hash_placeholder": "Identity hash (hex)",
+ "connect": "Connect",
+ "disconnect": "Disconnect",
+ "no_peers": "No connected peers yet.",
+ "no_files": "No local files in the sync inventory yet.",
+ "select_peer": "Select a peer",
+ "browse": "Browse",
+ "no_remote_files": "No remote files listed. Connect and browse a peer.",
+ "download": "Download",
+ "acl_enforce": "Enforce ACL (deny by default)",
+ "acl_grant": "Grant",
+ "acl_updated": "ACL updated",
+ "started": "FileSync started",
+ "stopped": "FileSync stopped",
+ "announced": "Announce sent",
+ "connected": "Peer connect requested",
+ "disconnected": "Peer disconnected",
+ "browse_done": "Browse complete",
+ "download_started": "Download requested",
+ "file_updated": "File updated",
+ "file_deleted": "File deleted",
+ "copied": "Copied to clipboard",
+ "error": "FileSync error"
}
}

diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index a28d162a..c6c2653b 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -2794,8 +2794,8 @@
"description": "IP地道通向Reticulum链接(正在开发)."
},
"rns_filesync": {
- "title": "RNS 文件同步",
- "description": "网络端点之间的文件同步( 正在开发中) 。"
+ "title": "RNS FileSync",
+ "description": "Sync a directory with mesh peers over Reticulum using destination hashes."
},
"bots": {
"title": "LXMFy 波兹",
@@ -3494,7 +3494,9 @@
"nav_rnsh": "RNSH",
"nav_rnsh_desc": "通过 Reticulum 的远程 Shell",
"nav_rnx": "RNX",
- "nav_rnx_desc": "通过 Reticulum 远程执行命令"
+ "nav_rnx_desc": "通过 Reticulum 远程执行命令",
+ "nav_rns_filesync": "RNS FileSync",
+ "nav_rns_filesync_desc": "Directory sync over Reticulum"
},
"relay_chat": {
"title": "中继聊天",
@@ -3792,5 +3794,57 @@
"windows_screen_security_desc": "MeshChatX 可使用 Windows 内容保护(与媒体应用相同的 DRM 风格显示标志),使截图、录屏和 Windows Recall 无法捕获应用窗口。之后可在 设置 → 隐私 中更改。",
"windows_screen_security_enable": "启用屏幕安全",
"windows_screen_security_later": "暂不"
+ },
+ "rns_filesync": {
+ "eyebrow": "Directory sync",
+ "title": "RNS FileSync",
+ "description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
+ "usage_steps": "How to use FileSync:",
+ "step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
+ "step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
+ "step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
+ "tab_status": "Status",
+ "tab_peers": "Peers",
+ "tab_files": "Files",
+ "tab_browse": "Browse",
+ "tab_acl": "ACL",
+ "sync_directory": "Sync directory",
+ "sync_directory_placeholder": "Path under this identity storage",
+ "announce_interval": "Announce interval (seconds)",
+ "monitor": "Watch directory for changes",
+ "start": "Start",
+ "stop": "Stop",
+ "announce": "Announce now",
+ "refresh": "Refresh",
+ "running": "Running",
+ "yes": "Yes",
+ "no": "No",
+ "peers_count": "Peers",
+ "files_count": "Files",
+ "destination_hash": "Destination hash",
+ "last_progress": "Last progress",
+ "peer_hash_placeholder": "Identity hash (hex)",
+ "connect": "Connect",
+ "disconnect": "Disconnect",
+ "no_peers": "No connected peers yet.",
+ "no_files": "No local files in the sync inventory yet.",
+ "select_peer": "Select a peer",
+ "browse": "Browse",
+ "no_remote_files": "No remote files listed. Connect and browse a peer.",
+ "download": "Download",
+ "acl_enforce": "Enforce ACL (deny by default)",
+ "acl_grant": "Grant",
+ "acl_updated": "ACL updated",
+ "started": "FileSync started",
+ "stopped": "FileSync stopped",
+ "announced": "Announce sent",
+ "connected": "Peer connect requested",
+ "disconnected": "Peer disconnected",
+ "browse_done": "Browse complete",
+ "download_started": "Download requested",
+ "file_updated": "File updated",
+ "file_deleted": "File deleted",
+ "copied": "Copied to clipboard",
+ "error": "FileSync error"
}
}

diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.js
index a375ca40..e3cd48c2 100644
--- a/meshchatx/src/frontend/main.js
+++ b/meshchatx/src/frontend/main.js
@@ -171,6 +171,11 @@ const router = createRouter({
path: "/rncp",
component: () => import("./components/rncp/RNCPPage.vue"),
},
+ {
+ name: "rns-filesync",
+ path: "/rns-filesync",
+ component: () => import("./components/filesync/RnsFilesyncPage.vue"),
+ },
{
name: "rnsh",
path: "/rnsh",

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/tools.md b/meshchatx/src/frontend/public/meshchatx-docs/en/tools.md
index b35d3ec9..8026202c 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/en/tools.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/tools.md
@@ -17,12 +17,15 @@ Use these when messages or pages fail despite interfaces showing as enabled.
## File transfer and shell
-| Tool | Purpose |
-| ---- | ------------------------------------------ |
-| RNCP | Send or fetch files over Reticulum |
-| RNSH | Remote shell sessions with streamed output |
+| Tool | Purpose |
+| ------------ | ---------------------------------------------------- |
+| RNCP | Send or fetch files over Reticulum |
+| RNS FileSync | Sync a directory with peers over `rns_filesync.filesync` |
+| RNSH | Remote shell sessions with streamed output |
RNCP progress events arrive on the WebSocket as `rncp.transfer.progress`.
+FileSync progress and peer events use `filesync.sync.progress`, `filesync.peer.connected`, `filesync.peer.disconnected`, `filesync.file.updated`, `filesync.file.deleted`, and `filesync.error`.
+FileSync uses the bundled `rns_filesync` package and keeps sync state under the active identity storage directory.
## Messaging helpers
@@ -69,7 +72,7 @@ Translator calls respect **privacy mode**. When privacy mode blocks outbound HTT
## Coming soon
-The registry marks **RNS Tunnel** and **RNS FileSync** as coming soon. They do not have routes in the current release.
+The registry marks **RNS Tunnel** as coming soon. It does not have a route in the current release.
## Relay chat server

diff --git a/meshchatx/src/frontend/public/vendor/visualiser-wasm/integrity.json b/meshchatx/src/frontend/public/vendor/visualiser-wasm/integrity.json
index 939ece90..555a4c91 100644
--- a/meshchatx/src/frontend/public/vendor/visualiser-wasm/integrity.json
+++ b/meshchatx/src/frontend/public/vendor/visualiser-wasm/integrity.json
@@ -1,6 +1,6 @@
{
"version": "1.2.0",
- "wasm": "sha384-Z1L+iI6+ud0bH8JYg54AHdwlr+/43VFTU6s91BEQQpiTjEAfGCnH2CweclVMpX5x",
+ "wasm": "sha384-u89AYwtv9w30MHieh7YmTmorpfsR5A8bh+9wRJR1rgvqaRc2xjgxEiM5mvJAsA9V",
"wasmExec": "sha384-PWCs+V4BDf9yY1yjkD/p+9xNEs4iEbuvq+HezAOJiY3XL5GI6VyJXMsvnjiwNbce",
"wasmExecSource": "/usr/lib/go/lib/wasm/wasm_exec.js"
}

diff --git a/pyproject.toml b/pyproject.toml
index 8f7d5a9b..7c5a16bf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -44,13 +44,14 @@ meshchatx = "meshchatx.meshchat:main"
meshchat = "meshchatx.meshchat:main"
meshchatx-repository-http = "meshchatx.repository_http_standalone:main"
lxmfy = "lxmfy.cli:main"
+rns-filesync = "rns_filesync.cli:main"
[project.urls]
Homepage = "https://github.com/Quad4-Software/MeshChatX"
[tool.setuptools.packages.find]
-where = [".", "vendor/lxmfy"]
-include = ["meshchatx*", "lxmfy*"]
+where = [".", "vendor/lxmfy", "vendor/rns_filesync"]
+include = ["meshchatx*", "lxmfy*", "rns_filesync*"]
exclude = ["tests*"]
namespaces = false

diff --git a/pyrightconfig.json b/pyrightconfig.json
index e2f79eb0..e072d1e0 100644
--- a/pyrightconfig.json
+++ b/pyrightconfig.json
@@ -1,7 +1,7 @@
{
"include": ["meshchatx/src/backend", "meshchatx/src/env_utils.py"],
"exclude": ["**/vendor/**", "**/build/**", "**/node_modules/**", "**/__pycache__/**"],
- "extraPaths": [".", "vendor/lxmfy"],
+ "extraPaths": [".", "vendor/lxmfy", "vendor/rns_filesync"],
"stubPath": "typings",
"pythonVersion": "3.11",
"typeCheckingMode": "basic",

diff --git a/scripts/ci/verify-package-contents.sh b/scripts/ci/verify-package-contents.sh
index b76850a5..8bb3f66b 100755
--- a/scripts/ci/verify-package-contents.sh
+++ b/scripts/ci/verify-package-contents.sh
@@ -53,7 +53,7 @@ record_hit() {
# Do not use a bare /(^|/)android(/|$)/ pattern: LXST ships
# LXST/Platforms/android for mobile hosts. That path must remain allowed in
# APKs and must not false-fail Docker scans of site-packages.
-COMMON_DENY_RE='(^|/)\.git(/|$)|(^|/)node_modules(/|$)|(^|/)\.pnpm-store(/|$)|(^|/)\.venv(/|$)|(^|/)vendor/offline(/|$)|(^|/)vendor/lxmfy/tests(/|$)|(^|/)vendor/lxmfy/docs(/|$)|(^|/)vendor/lxmfy/docker(/|$)|(^|/)\.github(/|$)|(^|/)docs/agents(/|$)|(^|/)screenshots(/|$)|(^|/)\.pytest_cache(/|$)|(^|/)mutants(/|$)|(^|/)coverage(/|$)'
+COMMON_DENY_RE='(^|/)\.git(/|$)|(^|/)node_modules(/|$)|(^|/)\.pnpm-store(/|$)|(^|/)\.venv(/|$)|(^|/)vendor/offline(/|$)|(^|/)vendor/lxmfy/tests(/|$)|(^|/)vendor/lxmfy/docs(/|$)|(^|/)vendor/lxmfy/docker(/|$)|(^|/)vendor/rns_filesync/tests(/|$)|(^|/)vendor/rns_filesync/docs(/|$)|(^|/)vendor/rns_filesync/docker(/|$)|(^|/)vendor/rns_filesync/packaging(/|$)|(^|/)vendor/rns_filesync/sideband(/|$)|(^|/)vendor/rns_filesync/man(/|$)|(^|/)vendor/rns_filesync/scripts(/|$)|(^|/)\.github(/|$)|(^|/)docs/agents(/|$)|(^|/)screenshots(/|$)|(^|/)\.pytest_cache(/|$)|(^|/)mutants(/|$)|(^|/)coverage(/|$)'
BYTECODE_DENY_RE='(^|/)__pycache__(/|$)'

diff --git a/tests/backend/conftest.py b/tests/backend/conftest.py
index 76fbc0f1..dbd94c2b 100644
--- a/tests/backend/conftest.py
+++ b/tests/backend/conftest.py
@@ -183,6 +183,9 @@ def mock_app(db, tmp_path, temp_db):
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
+ stack.enter_context(
+ patch("meshchatx.src.backend.identity_context.RnsFilesyncHandler"),
+ )
stack.enter_context(
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
)

diff --git a/tests/backend/eect/packs/test_auth_surface_pack.py b/tests/backend/eect/packs/test_auth_surface_pack.py
index 3e9188c3..8a471602 100644
--- a/tests/backend/eect/packs/test_auth_surface_pack.py
+++ b/tests/backend/eect/packs/test_auth_surface_pack.py
@@ -24,6 +24,15 @@ _MUTATING_SAMPLES = (
("PATCH", "/api/v1/server/security", {"web_ui_ip_allowlist": ""}),
("POST", "/api/v1/app/tutorial/seen", {}),
("POST", "/api/v1/app/changelog/seen", {"version": "999.999.999"}),
+ ("POST", "/api/v1/filesync/start", {}),
+ ("POST", "/api/v1/filesync/stop", {}),
+ ("POST", "/api/v1/filesync/announce", {}),
+ ("POST", "/api/v1/filesync/connect", {"identity_hash": "aa" * 16}),
+ ("POST", "/api/v1/filesync/disconnect", {"peer_id": "bb" * 16}),
+ ("POST", "/api/v1/filesync/browse", {"peer_id": "bb" * 16}),
+ ("POST", "/api/v1/filesync/download", {"peer_id": "bb" * 16, "path": "a.txt"}),
+ ("POST", "/api/v1/filesync/acl", {"enforce": False}),
+ ("PATCH", "/api/v1/filesync/settings", {"monitor": True}),
)
@@ -42,6 +51,28 @@ def _make_aio_app(mock_app, use_https: bool = False):
return aio_app
+def _stub_filesync_handler(mock_app):
+ handler = mock_app.rns_filesync_handler
+ if handler is None:
+ return
+ handler.reticulum = object()
+ handler.start.return_value = {"ok": True, "running": True}
+ handler.stop.return_value = {"ok": True, "running": False}
+ handler.announce_now.return_value = {"ok": True}
+ handler.connect_peer.return_value = {"ok": True, "peer_id": "aa" * 16}
+ handler.disconnect_peer.return_value = {"ok": True, "peer_id": "bb" * 16}
+ handler.browse_peer.return_value = {"ok": True, "files": []}
+ handler.download_file.return_value = {"ok": True, "path": "a.txt"}
+ handler.update_acl.return_value = {"ok": True, "enforce": False, "rules": {}}
+ handler.update_settings.return_value = {
+ "ok": True,
+ "sync_directory": "/tmp",
+ "monitor": True,
+ "announce_interval": 300,
+ "running": False,
+ }
+
+
@pytest.mark.asyncio
async def test_eect_mutating_without_csrf_rejected(mock_app, monkeypatch):
with eect_scenario("auth.csrf.mutating_without_token") as (_s, _seed, rng):
@@ -65,6 +96,7 @@ async def test_eect_mutating_without_csrf_rejected(mock_app, monkeypatch):
async def test_eect_mutating_with_csrf_accepted(mock_app, monkeypatch):
with eect_scenario("auth.csrf.mutating_with_token") as (_s, _seed, rng):
monkeypatch.delenv("MESHCHAT_DISABLE_CSRF", raising=False)
+ _stub_filesync_handler(mock_app)
aio_app = _make_aio_app(mock_app)
samples = list(_MUTATING_SAMPLES)
rng.shuffle(samples)

diff --git a/tests/backend/eect/packs/test_identity_switch_pack.py b/tests/backend/eect/packs/test_identity_switch_pack.py
index 5a4158ee..fcd1cb7a 100644
--- a/tests/backend/eect/packs/test_identity_switch_pack.py
+++ b/tests/backend/eect/packs/test_identity_switch_pack.py
@@ -45,6 +45,7 @@ def mock_rns(tmp_path):
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
patch("meshchatx.src.backend.identity_context.RNCPHandler"),
+ patch("meshchatx.src.backend.identity_context.RnsFilesyncHandler"),
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),

diff --git a/tests/backend/http_api_response_registry.py b/tests/backend/http_api_response_registry.py
index 84d2dc36..22e70604 100644
--- a/tests/backend/http_api_response_registry.py
+++ b/tests/backend/http_api_response_registry.py
@@ -84,6 +84,10 @@ from tests.backend.http_api_response_schemas import (
RETICULUM_INSTANCE_SCHEMA,
RNCP_STATUS_SCHEMA,
RNCP_TRANSFER_SCHEMA,
+ FILESYNC_ACL_SCHEMA,
+ FILESYNC_FILES_SCHEMA,
+ FILESYNC_PEERS_SCHEMA,
+ FILESYNC_STATUS_SCHEMA,
RNPATH_RATES_SCHEMA,
RNPATH_TABLE_SCHEMA,
RNPATH_TRACE_SCHEMA,
@@ -449,6 +453,10 @@ HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = (
allow_statuses=(200, 404),
alt_schemas=(MESSAGE_ENVELOPE_SCHEMA,),
),
+ HttpJsonContract("GET", "/api/v1/filesync/status", FILESYNC_STATUS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/filesync/peers", FILESYNC_PEERS_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/filesync/files", FILESYNC_FILES_SCHEMA),
+ HttpJsonContract("GET", "/api/v1/filesync/acl", FILESYNC_ACL_SCHEMA),
HttpJsonContract("GET", "/api/v1/rnpath/table", RNPATH_TABLE_SCHEMA),
HttpJsonContract("GET", "/api/v1/rnpath/rates", RNPATH_RATES_SCHEMA),
HttpJsonContract(

diff --git a/tests/backend/http_api_response_schemas.py b/tests/backend/http_api_response_schemas.py
index 598156c8..80a2efff 100644
--- a/tests/backend/http_api_response_schemas.py
+++ b/tests/backend/http_api_response_schemas.py
@@ -528,6 +528,48 @@ RNCP_STATUS_SCHEMA: dict = {
"additionalProperties": True,
}
+FILESYNC_STATUS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["running", "sync_directory"],
+ "properties": {
+ "running": _BOOLEAN,
+ "sync_directory": _STRING,
+ "identity_hash": {"type": ["string", "null"]},
+ "destination_hash": {"type": ["string", "null"]},
+ "peers": _INTEGER,
+ "files": _INTEGER,
+ "whitelist": _BOOLEAN,
+ "monitor": _BOOLEAN,
+ "announce_interval": _INTEGER,
+ "config_directory": _STRING,
+ },
+ "additionalProperties": True,
+}
+
+FILESYNC_PEERS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["peers"],
+ "properties": {"peers": _ARRAY},
+ "additionalProperties": True,
+}
+
+FILESYNC_FILES_SCHEMA: dict = {
+ "type": "object",
+ "required": ["files"],
+ "properties": {"files": _ARRAY},
+ "additionalProperties": True,
+}
+
+FILESYNC_ACL_SCHEMA: dict = {
+ "type": "object",
+ "required": ["enforce", "rules"],
+ "properties": {
+ "enforce": _BOOLEAN,
+ "rules": _OBJECT,
+ },
+ "additionalProperties": True,
+}
+
RNCP_TRANSFER_SCHEMA: dict = {
"type": "object",
"required": ["transfer"],

diff --git a/tests/backend/test_package_version_resolution.py b/tests/backend/test_package_version_resolution.py
index bee47bdb..d96a138d 100644
--- a/tests/backend/test_package_version_resolution.py
+++ b/tests/backend/test_package_version_resolution.py
@@ -65,6 +65,28 @@ def test_get_package_version_resolves_lxmfy_when_metadata_missing():
assert v[0].isdigit()
+def test_get_package_version_resolves_rns_filesync_when_metadata_missing():
+ def _missing(*_a, **_k):
+ raise importlib.metadata.PackageNotFoundError
+
+ real_import = __import__
+
+ def _import_module(name, package=None):
+ if name == "rns_filesync":
+ return real_import(name)
+ return real_import(name, package=package)
+
+ with (
+ patch("importlib.metadata.version", side_effect=_missing),
+ patch("importlib.metadata.distribution", side_effect=_missing),
+ patch("importlib.metadata.packages_distributions", return_value={}),
+ patch("importlib.import_module", side_effect=_import_module),
+ ):
+ v = ReticulumMeshChat.get_package_version("rns-filesync")
+ assert v != "unknown"
+ assert v[0].isdigit()
+
+
@pytest.mark.parametrize(
"package",
(
@@ -76,6 +98,8 @@ def test_get_package_version_resolves_lxmfy_when_metadata_missing():
"ply",
"bcrypt",
"lxmfy",
+ "rns-filesync",
+ "rns_filesync",
),
)
def test_app_info_dependency_keys_resolve_in_dev_env(package: str):

diff --git a/tests/backend/test_rns_filesync_handler.py b/tests/backend/test_rns_filesync_handler.py
new file mode 100644
index 00000000..ff781faf
--- /dev/null
+++ b/tests/backend/test_rns_filesync_handler.py
@@ -0,0 +1,132 @@
+# SPDX-License-Identifier: 0BSD
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from meshchatx.src.backend.rns_filesync_handler import RnsFilesyncHandler
+
+
+@pytest.fixture
+def mock_identity():
+ return SimpleNamespace(hash=b"\xaa" * 16)
+
+
+@pytest.fixture
+def handler(mock_identity, tmp_path):
+ storage = tmp_path / "identity"
+ storage.mkdir()
+ return RnsFilesyncHandler(
+ reticulum_instance=MagicMock(name="reticulum"),
+ identity=mock_identity,
+ storage_dir=str(storage),
+ )
+
+
+def test_default_status_not_running(handler, mock_identity):
+ status = handler.get_status()
+ assert status["running"] is False
+ assert status["destination_hash"] is None
+ assert status["identity_hash"] == mock_identity.hash.hex()
+ assert status["sync_directory"].endswith("filesync/sync")
+ assert "filesync" in status["config_directory"]
+
+
+def test_update_settings_persists_sync_directory(handler):
+ result = handler.update_settings(sync_directory="/tmp/filesync-alt", monitor=False)
+ assert result["ok"] is True
+ assert result["monitor"] is False
+ status = handler.get_status()
+ assert status["sync_directory"] == "/tmp/filesync-alt"
+ assert status["monitor"] is False
+
+
+def test_acl_grant_and_persist(handler, tmp_path):
+ peer = "bb" * 16
+ result = handler.update_acl(
+ identity_hash=peer,
+ perms=["read", "write"],
+ enforce=True,
+ )
+ assert result["ok"] is True
+ assert result["enforce"] is True
+ assert peer in result["rules"]["read"]
+ assert peer in result["rules"]["write"]
+
+ acl_path = tmp_path / "identity" / "filesync" / "acl.txt"
+ assert acl_path.is_file()
+ text = acl_path.read_text(encoding="utf-8")
+ assert f"r:{peer}" in text
+ assert f"w:{peer}" in text
+
+ again = handler.get_acl()
+ assert again["enforce"] is True
+ assert peer in again["rules"]["read"]
+
+
+def test_connect_requires_running(handler):
+ result = handler.connect_peer("cc" * 16)
+ assert result["ok"] is False
+ assert "not running" in result["error"]
+
+
+@patch("meshchatx.src.backend.rns_filesync_handler.FileSyncService")
+def test_start_stop_and_teardown(mock_service_cls, handler):
+ service = MagicMock()
+ service.start.return_value = "dd" * 16
+ service.get_status.return_value = {
+ "running": True,
+ "sync_directory": handler._sync_directory,
+ "identity_hash": "aa" * 16,
+ "destination_hash": "dd" * 16,
+ "peers": 0,
+ "files": 0,
+ "whitelist": False,
+ "monitor": True,
+ }
+ service.list_peers.return_value = [{"peer_id": "ee" * 16, "status": 1}]
+ service.list_files.return_value = [{"path": "a.txt", "size": 1}]
+ service.connect_peer.return_value = {"ok": True, "peer_id": "ee" * 16}
+ service.browse_peer.return_value = [{"path": "remote.txt", "size": 2}]
+ service.download_file.return_value = {"ok": True, "path": "remote.txt"}
+ mock_service_cls.return_value = service
+
+ started = handler.start(monitor=True, announce_interval=120)
+ assert started["ok"] is True
+ assert started["destination_hash"] == "dd" * 16
+ mock_service_cls.assert_called_once()
+ kwargs = mock_service_cls.call_args.kwargs
+ assert kwargs["own_reticulum"] is False
+ assert kwargs["reticulum"] is handler.reticulum
+ service.start.assert_called_once()
+
+ assert handler.list_peers()[0]["peer_id"] == "ee" * 16
+ assert handler.list_files()[0]["path"] == "a.txt"
+ assert handler.connect_peer("ee" * 16)["ok"] is True
+ assert handler.browse_peer("ee" * 16)["ok"] is True
+ assert handler.download_file("ee" * 16, "remote.txt")["ok"] is True
+ assert handler.announce_now()["ok"] is True
+ assert handler.disconnect_peer("ee" * 16)["ok"] is True
+
+ stopped = handler.stop()
+ assert stopped["ok"] is True
+ assert stopped["running"] is False
+ service.stop.assert_called()
+ assert handler.service is None
+
+ service.stop.reset_mock()
+ handler.service = service
+ handler.teardown()
+ service.stop.assert_called()
+ assert handler.service is None
+
+
+def test_settings_reject_sync_dir_change_while_running(handler):
+ handler.service = MagicMock()
+ handler.service.get_status.return_value = {"running": True}
+ result = handler.update_settings(sync_directory="/tmp/other")
+ assert result["ok"] is False
+ assert "stop filesync" in result["error"]

diff --git a/tests/backend/test_rns_filesync_security.py b/tests/backend/test_rns_filesync_security.py
new file mode 100644
index 00000000..bcefc5b6
--- /dev/null
+++ b/tests/backend/test_rns_filesync_security.py
@@ -0,0 +1,336 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Adversarial and Hypothesis fuzz coverage for RNS FileSync handler."""
+
+from __future__ import annotations
+
+import threading
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+from hypothesis import HealthCheck, given, settings
+from hypothesis import strategies as st
+
+from meshchatx.src.backend.rns_filesync_handler import RnsFilesyncHandler
+from rns_filesync.paths import PathJailError, normalize_relpath
+from rns_filesync.permissions import PermissionStore
+
+_TRAVERSAL_PAYLOADS = (
+ "../etc/passwd",
+ "..\\windows\\system32",
+ "a/../../b",
+ "/etc/passwd",
+ "C:\\Windows\\System32",
+ "a\x00b",
+ "",
+ ".",
+ "..",
+ "//evil",
+ "\\\\evil",
+ "foo/../../../etc/shadow",
+ ".rns-filesync.db",
+ "dir/.rns-xfer-temp",
+)
+
+_BAD_HASHES = (
+ "",
+ " ",
+ "zz",
+ "not-hex",
+ "../aabb",
+ "a" * 15,
+ "g" * 32,
+ "\x00" * 16,
+ "' OR '1'='1",
+ "../../identity",
+ "а" * 32,
+)
+
+
+@pytest.fixture
+def handler(tmp_path):
+ storage = tmp_path / "identity_a"
+ storage.mkdir()
+ identity = SimpleNamespace(hash=b"\xaa" * 16)
+ return RnsFilesyncHandler(
+ reticulum_instance=MagicMock(name="reticulum"),
+ identity=identity,
+ storage_dir=str(storage),
+ )
+
+
+def test_path_traversal_payloads_rejected_by_normalize():
+ for payload in _TRAVERSAL_PAYLOADS:
+ with pytest.raises(PathJailError):
+ normalize_relpath(payload)
+
+
+def test_download_rejects_path_traversal(handler):
+ handler.service = MagicMock()
+ handler.service.download_file.side_effect = lambda peer, path: {
+ "ok": False,
+ "error": f"path jail: {path}",
+ }
+ for payload in _TRAVERSAL_PAYLOADS:
+ result = handler.download_file("bb" * 16, payload)
+ assert result["ok"] is False
+ assert "error" in result
+
+
+def test_download_and_browse_require_running(handler):
+ assert handler.download_file("bb" * 16, "a.txt")["ok"] is False
+ assert handler.browse_peer("bb" * 16)["ok"] is False
+ assert handler.connect_peer("bb" * 16)["ok"] is False
+
+
+@pytest.mark.parametrize("bad_hash", _BAD_HASHES)
+def test_connect_rejects_or_handles_bad_hashes(handler, bad_hash):
+ handler.service = MagicMock()
+ handler.service.connect_peer.return_value = {"ok": False, "error": "bad peer"}
+ result = handler.connect_peer(bad_hash)
+ assert isinstance(result, dict)
+ assert "ok" in result
+ if not str(bad_hash).strip():
+ assert result["ok"] is False
+
+
+@pytest.mark.parametrize("bad_hash", _BAD_HASHES)
+def test_acl_grant_handles_bad_hashes(handler, bad_hash):
+ result = handler.update_acl(
+ identity_hash=bad_hash,
+ perms=["read"],
+ enforce=True,
+ )
+ assert isinstance(result, dict)
+ assert "ok" in result
+
+
+def test_acl_enforce_denies_stranger_by_default_rules(handler):
+ peer = "cc" * 16
+ stranger = "dd" * 16
+ handler.update_acl(identity_hash=peer, perms=["read"], enforce=True)
+ acl = handler.get_acl()
+ assert acl["enforce"] is True
+ perms = PermissionStore()
+ for rule_perm, targets in acl["rules"].items():
+ for target in targets:
+ short = {"read": "r", "write": "w", "delete": "d"}[rule_perm]
+ perms.add_rule(f"{short}:{target}")
+ assert perms.check(peer, "read") is True
+ assert perms.check(stranger, "read") is False
+ assert perms.check(stranger, "write") is False
+
+
+def test_acl_rules_text_replace_does_not_leak_old_rules(handler):
+ peer_a = "11" * 16
+ peer_b = "22" * 16
+ handler.update_acl(identity_hash=peer_a, perms=["read", "write"], enforce=True)
+ handler.update_acl(
+ rules_text=f"r:{peer_b}",
+ replace=True,
+ enforce=True,
+ )
+ acl = handler.get_acl()
+ assert peer_a not in acl["rules"].get("read", [])
+ assert peer_b in acl["rules"].get("read", [])
+ assert peer_a not in acl["rules"].get("write", [])
+
+
+def test_oversized_rules_text_does_not_raise(handler):
+ huge = ("r:" + ("ab" * 16) + "\n") * 5000
+ result = handler.update_acl(rules_text=huge, replace=True, enforce=True)
+ assert result["ok"] is True
+ assert isinstance(result["rules"], dict)
+
+
+def test_teardown_clears_service_and_isolates_storage(tmp_path):
+ storage_a = tmp_path / "a"
+ storage_b = tmp_path / "b"
+ storage_a.mkdir()
+ storage_b.mkdir()
+ identity_a = SimpleNamespace(hash=b"\xaa" * 16)
+ identity_b = SimpleNamespace(hash=b"\xbb" * 16)
+ ha = RnsFilesyncHandler(MagicMock(), identity_a, str(storage_a))
+ hb = RnsFilesyncHandler(MagicMock(), identity_b, str(storage_b))
+ peer = "ee" * 16
+ ha.update_acl(identity_hash=peer, perms=["read"], enforce=True)
+ hb.update_acl(identity_hash=peer, perms=["write"], enforce=True)
+
+ svc = MagicMock()
+ svc.get_status.return_value = {"running": True}
+ ha.service = svc
+ ha.teardown()
+ assert ha.service is None
+ svc.stop.assert_called()
+
+ assert "read" in ha.get_acl()["rules"]
+ assert peer in ha.get_acl()["rules"]["read"]
+ assert "write" in hb.get_acl()["rules"]
+ assert peer in hb.get_acl()["rules"]["write"]
+ assert peer not in ha.get_acl()["rules"].get("write", [])
+ assert (storage_a / "filesync" / "acl.txt").is_file()
+ assert (storage_b / "filesync" / "acl.txt").is_file()
+ assert storage_a.resolve() != storage_b.resolve()
+
+
+def test_concurrent_acl_mutations_stable(handler):
+ peer = "ff" * 16
+ errors: list[BaseException] = []
+
+ def worker(idx: int):
+ try:
+ for i in range(40):
+ handler.update_acl(
+ identity_hash=peer,
+ perms=["read"] if (idx + i) % 2 == 0 else ["write"],
+ enforce=True,
+ )
+ acl = handler.get_acl()
+ assert isinstance(acl["rules"], dict)
+ assert acl["enforce"] is True
+ except BaseException as exc:
+ errors.append(exc)
+
+ threads = [threading.Thread(target=worker, args=(n,)) for n in range(8)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join(timeout=10)
+ assert not errors
+ final = handler.get_acl()
+ assert final["enforce"] is True
+
+
+@settings(
+ max_examples=40,
+ deadline=None,
+ suppress_health_check=[HealthCheck.too_slow, HealthCheck.function_scoped_fixture],
+)
+@given(
+ path=st.text(
+ alphabet=st.characters(
+ whitelist_categories=("L", "N", "P", "S", "Z", "Cc"),
+ ),
+ min_size=0,
+ max_size=400,
+ ),
+)
+def test_download_path_fuzz_never_raises(handler, path):
+ handler.service = MagicMock()
+ handler.service.download_file.side_effect = lambda peer, p: {
+ "ok": False,
+ "error": "denied",
+ "path": p,
+ }
+ result = handler.download_file("aa" * 16, path)
+ assert isinstance(result, dict)
+ assert "ok" in result
+
+
+@settings(
+ max_examples=40,
+ deadline=None,
+ suppress_health_check=[HealthCheck.too_slow, HealthCheck.function_scoped_fixture],
+)
+@given(
+ identity_hash=st.one_of(
+ st.text(min_size=0, max_size=80),
+ st.binary(min_size=0, max_size=40).map(lambda b: b.hex()),
+ st.sampled_from(list(_BAD_HASHES)),
+ ),
+ perms=st.lists(
+ st.sampled_from(["read", "write", "delete", "admin", "nope", ""]),
+ max_size=6,
+ ),
+ enforce=st.booleans(),
+ rules_text=st.one_of(st.none(), st.text(max_size=500)),
+ replace=st.booleans(),
+)
+def test_acl_update_fuzz_never_raises(
+ handler,
+ identity_hash,
+ perms,
+ enforce,
+ rules_text,
+ replace,
+):
+ result = handler.update_acl(
+ identity_hash=identity_hash,
+ perms=perms,
+ enforce=enforce,
+ rules_text=rules_text,
+ replace=replace,
+ )
+ assert isinstance(result, dict)
+ assert "ok" in result
+
+
+@settings(
+ max_examples=30,
+ deadline=None,
+ suppress_health_check=[HealthCheck.too_slow, HealthCheck.function_scoped_fixture],
+)
+@given(
+ sync_directory=st.one_of(
+ st.none(),
+ st.text(min_size=0, max_size=200),
+ st.sampled_from(["", " ", "../escape", "/tmp/x", "~/.ssh"]),
+ ),
+ monitor=st.one_of(st.none(), st.booleans()),
+ announce_interval=st.one_of(
+ st.none(),
+ st.integers(min_value=-10, max_value=10_000),
+ st.text(max_size=20),
+ ),
+)
+def test_settings_fuzz_never_raises(handler, sync_directory, monitor, announce_interval):
+ result = handler.update_settings(
+ sync_directory=sync_directory,
+ monitor=monitor,
+ announce_interval=announce_interval,
+ )
+ assert isinstance(result, dict)
+ assert "ok" in result
+
+
+@settings(
+ max_examples=25,
+ deadline=None,
+ suppress_health_check=[HealthCheck.too_slow, HealthCheck.function_scoped_fixture],
+)
+@given(peer_id=st.text(min_size=0, max_size=100))
+def test_disconnect_fuzz_never_raises(handler, peer_id):
+ handler.service = MagicMock()
+ result = handler.disconnect_peer(peer_id)
+ assert isinstance(result, dict)
+ assert "ok" in result
+
+
+@patch("meshchatx.src.backend.rns_filesync_handler.FileSyncService")
+def test_start_with_malicious_interval_rejected(mock_service_cls, handler):
+ result = handler.start(announce_interval=1)
+ assert result["ok"] is False
+ mock_service_cls.assert_not_called()
+
+
+@patch("meshchatx.src.backend.rns_filesync_handler.FileSyncService")
+def test_start_wires_callbacks_and_reuses_host_reticulum(mock_service_cls, handler):
+ service = MagicMock()
+ service.start.return_value = "ab" * 16
+ service.get_status.return_value = {
+ "running": True,
+ "sync_directory": handler._sync_directory,
+ "identity_hash": "aa" * 16,
+ "destination_hash": "ab" * 16,
+ "peers": 0,
+ "files": 0,
+ "whitelist": False,
+ "monitor": True,
+ }
+ mock_service_cls.return_value = service
+ result = handler.start()
+ assert result["ok"] is True
+ assert mock_service_cls.call_args.kwargs["own_reticulum"] is False
+ assert service.on_error is not None
+ assert service.on_sync_progress is not None

diff --git a/tests/frontend/NetworkVisualiserToolbar.test.js b/tests/frontend/NetworkVisualiserToolbar.test.js
index 797f2e46..5f2be519 100644
--- a/tests/frontend/NetworkVisualiserToolbar.test.js
+++ b/tests/frontend/NetworkVisualiserToolbar.test.js
@@ -11,6 +11,7 @@ describe("NetworkVisualiserToolbar", () => {
edgeCount: 8,
onlineInterfaceCount: 2,
offlineInterfaceCount: 1,
+ preferredRenderer: "auto",
engineMode: "wasm",
fps: 58,
...props,
@@ -27,23 +28,33 @@ describe("NetworkVisualiserToolbar", () => {
},
});
- it("shows WASM engine label and FPS", () => {
+ it("shows engine select and FPS", () => {
const wrapper = mountToolbar();
- expect(wrapper.text()).toContain("visualiser.engine_wasm");
+ const select = wrapper.find("#visualiser-engine-select");
+ expect(select.exists()).toBe(true);
+ expect(select.element.value).toBe("auto");
expect(wrapper.text()).toContain("58");
expect(wrapper.text()).toContain("visualiser.fps");
+ expect(wrapper.text()).toContain("visualiser.renderer_option_webgl");
+ expect(wrapper.text()).toContain("visualiser.renderer_option_vis");
});
- it("shows JS fallback engine label", () => {
- const wrapper = mountToolbar({ engineMode: "fallback", fps: 0 });
- expect(wrapper.text()).toContain("visualiser.engine_fallback");
- expect(wrapper.text()).toContain("--");
+ it("emits preferred renderer when engine select changes", async () => {
+ const wrapper = mountToolbar({ preferredRenderer: "auto", engineMode: "webgl" });
+ const select = wrapper.find("#visualiser-engine-select");
+ await select.setValue("webgl");
+ expect(wrapper.emitted("update:preferredRenderer")?.[0]).toEqual(["webgl"]);
+ await select.setValue("vis");
+ expect(wrapper.emitted("update:preferredRenderer")?.[1]).toEqual(["vis"]);
});
- it("shows WebGL engine label", () => {
- const wrapper = mountToolbar({ engineMode: "webgl", fps: 60 });
- expect(wrapper.text()).toContain("visualiser.engine_webgl");
- expect(wrapper.text()).toContain("60");
+ it("styles select from active engine mode", () => {
+ const webgl = mountToolbar({ engineMode: "webgl", preferredRenderer: "webgl", fps: 60 });
+ expect(webgl.find("#visualiser-engine-select").classes().join(" ")).toContain("text-sky-600");
+
+ const fallback = mountToolbar({ engineMode: "fallback", preferredRenderer: "vis", fps: 0 });
+ expect(fallback.find("#visualiser-engine-select").classes().join(" ")).toContain("text-amber-600");
+ expect(fallback.text()).toContain("--");
});
it("uses MDI magnify for search and refresh for update button", () => {

diff --git a/tests/frontend/RnsFilesyncPage.test.js b/tests/frontend/RnsFilesyncPage.test.js
new file mode 100644
index 00000000..032f5d59
--- /dev/null
+++ b/tests/frontend/RnsFilesyncPage.test.js
@@ -0,0 +1,136 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import RnsFilesyncPage from "@/components/filesync/RnsFilesyncPage.vue";
+import ToastUtils from "@/js/ToastUtils";
+
+vi.mock("@/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ info: vi.fn(),
+ warning: vi.fn(),
+ },
+}));
+
+vi.mock("@/js/registries/wsEventRegistry.js", () => ({
+ onWsEvent: vi.fn(),
+ offWsEvent: vi.fn(),
+}));
+
+describe("RnsFilesyncPage.vue", () => {
+ let apiMock;
+
+ beforeEach(() => {
+ apiMock = {
+ get: vi.fn(),
+ post: vi.fn(),
+ patch: vi.fn(),
+ };
+ window.api = apiMock;
+
+ apiMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/filesync/status") {
+ return Promise.resolve({
+ data: {
+ running: false,
+ sync_directory: "/tmp/sync",
+ peers: 0,
+ files: 0,
+ monitor: true,
+ announce_interval: 300,
+ },
+ });
+ }
+ if (url === "/api/v1/filesync/peers") {
+ return Promise.resolve({ data: { peers: [] } });
+ }
+ if (url === "/api/v1/filesync/files") {
+ return Promise.resolve({ data: { files: [] } });
+ }
+ if (url === "/api/v1/filesync/acl") {
+ return Promise.resolve({ data: { enforce: false, rules: {} } });
+ }
+ return Promise.resolve({ data: {} });
+ });
+ apiMock.post.mockResolvedValue({ data: { ok: true } });
+ apiMock.patch.mockResolvedValue({ data: { ok: true } });
+ });
+
+ afterEach(() => {
+ delete window.api;
+ vi.clearAllMocks();
+ });
+
+ const mountPage = () =>
+ mount(RnsFilesyncPage, {
+ global: {
+ mocks: {
+ $t: (key) => key,
+ },
+ stubs: {
+ MaterialDesignIcon: {
+ template: '<div class="mdi-stub" :data-icon-name="iconName"></div>',
+ props: ["iconName"],
+ },
+ ToolsPageHeader: {
+ template: "<div class='header-stub'>{{ title }}</div>",
+ props: ["title", "description", "eyebrow", "icon", "accent"],
+ },
+ },
+ },
+ });
+
+ it("renders and loads status", async () => {
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(apiMock.get).toHaveBeenCalledWith("/api/v1/filesync/status"));
+ expect(wrapper.text()).toContain("rns_filesync.title");
+ expect(wrapper.vm.syncDirectory).toBe("/tmp/sync");
+ });
+
+ it("starts filesync and toasts success", async () => {
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.syncDirectory).toBe("/tmp/sync"));
+ await wrapper.vm.startService();
+ expect(apiMock.post).toHaveBeenCalledWith(
+ "/api/v1/filesync/start",
+ expect.objectContaining({
+ sync_directory: "/tmp/sync",
+ monitor: true,
+ }),
+ );
+ expect(ToastUtils.success).toHaveBeenCalledWith("rns_filesync.started");
+ });
+
+ it("shows error toast when connect fails", async () => {
+ apiMock.post.mockRejectedValueOnce(new Error("path timeout"));
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.syncDirectory).toBe("/tmp/sync"));
+ wrapper.vm.status.running = true;
+ wrapper.vm.connectHash = "ab".repeat(16);
+ await wrapper.vm.connectPeer();
+ expect(ToastUtils.error).toHaveBeenCalled();
+ });
+
+ it("browses and downloads a remote file", async () => {
+ apiMock.post.mockImplementation((url) => {
+ if (url === "/api/v1/filesync/browse") {
+ return Promise.resolve({
+ data: { ok: true, files: [{ path: "notes.txt", size: 12 }] },
+ });
+ }
+ return Promise.resolve({ data: { ok: true } });
+ });
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.syncDirectory).toBe("/tmp/sync"));
+ wrapper.vm.status.running = true;
+ wrapper.vm.browsePeerId = "cd".repeat(16);
+ await wrapper.vm.browsePeer();
+ expect(wrapper.vm.remoteFiles).toEqual([{ path: "notes.txt", size: 12 }]);
+ await wrapper.vm.downloadFile("notes.txt");
+ expect(apiMock.post).toHaveBeenCalledWith("/api/v1/filesync/download", {
+ peer_id: "cd".repeat(16),
+ path: "notes.txt",
+ });
+ expect(ToastUtils.info).toHaveBeenCalledWith("rns_filesync.download_started");
+ });
+});

diff --git a/tests/frontend/ToolsPage.test.js b/tests/frontend/ToolsPage.test.js
index ba142f40..1f75845c 100644
--- a/tests/frontend/ToolsPage.test.js
+++ b/tests/frontend/ToolsPage.test.js
@@ -12,6 +12,7 @@ describe("ToolsPage.vue", () => {
{ path: "/ping", name: "ping", component: { template: "div" } },
{ path: "/rnprobe", name: "rnprobe", component: { template: "div" } },
{ path: "/rncp", name: "rncp", component: { template: "div" } },
+ { path: "/rns-filesync", name: "rns-filesync", component: { template: "div" } },
{ path: "/rnsh", name: "rnsh", component: { template: "div" } },
{ path: "/rnstatus", name: "rnstatus", component: { template: "div" } },
{ path: "/rnpath", name: "rnpath", component: { template: "div" } },

diff --git a/tests/frontend/behaviorContracts.test.js b/tests/frontend/behaviorContracts.test.js
index df9d1ff2..b25e6f57 100644
--- a/tests/frontend/behaviorContracts.test.js
+++ b/tests/frontend/behaviorContracts.test.js
@@ -174,11 +174,16 @@ describe("behavior contracts: Android Chaquopy Python sync", () => {
expect(gradle).toContain("vendor/lxmfy/lxmfy");
expect(gradle).toContain("syncLxmfyPython");
expect(gradle).toContain("src/main/python/lxmfy");
- expect(gradle).toMatch(
- /dependsOn\(tasks\.named\("syncMeshchatPython"\),\s*tasks\.named\("syncLxmfyPython"\)\)/
- );
- const initPy = readSource("vendor/lxmfy/lxmfy/__init__.py");
- expect(initPy.length).toBeGreaterThan(0);
+ expect(gradle).toContain("syncRnsFilesyncPython");
+ expect(gradle).toContain("vendor/rns_filesync/rns_filesync");
+ expect(gradle).toContain("src/main/python/rns_filesync");
+ expect(gradle).toMatch(/syncMeshchatPython/);
+ expect(gradle).toMatch(/syncLxmfyPython/);
+ expect(gradle).toMatch(/syncRnsFilesyncPython/);
+ const lxmfyInit = readSource("vendor/lxmfy/lxmfy/__init__.py");
+ expect(lxmfyInit.length).toBeGreaterThan(0);
+ const filesyncInit = readSource("vendor/rns_filesync/rns_filesync/__init__.py");
+ expect(filesyncInit.length).toBeGreaterThan(0);
});
it("Android wrapper clears stale storage lock before main()", () => {

diff --git a/tests/frontend/filesyncPackagingContracts.test.js b/tests/frontend/filesyncPackagingContracts.test.js
new file mode 100644
index 00000000..1e58c7ad
--- /dev/null
+++ b/tests/frontend/filesyncPackagingContracts.test.js
@@ -0,0 +1,34 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+const ROOT = resolve(__dirname, "../..");
+
+function readSource(relativePath) {
+ return readFileSync(resolve(ROOT, relativePath), "utf8");
+}
+
+describe("filesync packaging contracts", () => {
+ it("docker ignore excludes rns_filesync non-runtime trees", () => {
+ const dockerignore = readSource(".dockerignore");
+ expect(dockerignore).toContain("vendor/rns_filesync/tests/");
+ expect(dockerignore).toContain("vendor/rns_filesync/docker/");
+ expect(dockerignore).toContain("vendor/rns_filesync/packaging/");
+ expect(dockerignore).toContain("vendor/rns_filesync/sideband/");
+ });
+
+ it("package verify denylist covers rns_filesync bloat paths", () => {
+ const script = readSource("scripts/ci/verify-package-contents.sh");
+ expect(script).toContain("vendor/rns_filesync/tests");
+ expect(script).toContain("vendor/rns_filesync/docker");
+ expect(script).toContain("vendor/rns_filesync/packaging");
+ });
+
+ it("tools registry exposes filesync without comingSoon", () => {
+ const tools = readSource("meshchatx/src/frontend/js/registries/coreToolsEntries.js");
+ expect(tools).toContain('name: "rns-filesync"');
+ expect(tools).toContain('route: { name: "rns-filesync" }');
+ const block = tools.slice(tools.indexOf('name: "rns-filesync"'), tools.indexOf('name: "debug-logs"'));
+ expect(block).not.toContain("comingSoon");
+ });
+});

diff --git a/vendor/README.txt b/vendor/README.txt
index ba6992ea..91ebc844 100644
--- a/vendor/README.txt
+++ b/vendor/README.txt
@@ -6,3 +6,10 @@ lxmfy/
Declared version (pyproject): see vendor/lxmfy/pyproject.toml
Update: clone default branch, replace vendor/lxmfy (omit .git), align vendor/README
commit above, run poetry lock / uv lock, regenerate THIRD_PARTY_NOTICES if needed.
+
+rns_filesync/
+ Upstream: https://github.com/Quad4-Software/RNS-Filesync
+ Bundled revision: 12161f3f47d4c7421990e5790aa3d41e08fd623a
+ Declared version (pyproject): see vendor/rns_filesync/pyproject.toml
+ Update: clone default branch, replace vendor/rns_filesync (omit .git), align vendor/README
+ commit above, regenerate THIRD_PARTY_NOTICES if needed.

diff --git a/vendor/rns_filesync/.gitignore b/vendor/rns_filesync/.gitignore
new file mode 100644
index 00000000..dace3b78
--- /dev/null
+++ b/vendor/rns_filesync/.gitignore
@@ -0,0 +1,13 @@
+.venv/
+__pycache__/
+*.py[cod]
+*.egg-info/
+.eggs/
+dist/
+build/
+.pytest_cache/
+.coverage
+htmlcov/
+.ruff_cache/
+.hypothesis/
+*.tmp

diff --git a/vendor/rns_filesync/LICENSE b/vendor/rns_filesync/LICENSE
new file mode 100644
index 00000000..1e70c041
--- /dev/null
+++ b/vendor/rns_filesync/LICENSE
@@ -0,0 +1,9 @@
+Copyright 2025-2026 Sudo-Ivan / Quad4.io
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
\ No newline at end of file

diff --git a/vendor/rns_filesync/Makefile b/vendor/rns_filesync/Makefile
new file mode 100644
index 00000000..9d56791d
--- /dev/null
+++ b/vendor/rns_filesync/Makefile
@@ -0,0 +1,102 @@
+EDITOR ?= nano
+PYTHON ?= python3
+PIP ?= pip
+PREFIX ?= /usr/local
+DESTDIR ?=
+RNID_ID ?= e46112d44649266d71fe2193e00a4710
+RNID_KEY ?= $(HOME)/.rngit/client_identity
+RNS_REMOTE ?= rns://06a54b505bb67b25ef3f8097e8001edc/public/rns-filesync
+TAG ?= v1.0.0
+
+VERSION := $(shell $(PYTHON) -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])" 2>/dev/null || $(PYTHON) -c "from rns_filesync._meta import __version__; print(__version__)")
+BUILD_DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
+GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
+GIT_DIRTY := $(shell git diff --quiet 2>/dev/null && echo 0 || echo 1)
+
+.PHONY: all clean meta build wheel sdist sign upload release release-rns tag retag \
+ test test-live test-services test-bwrap install install-user man uninstall version help
+
+all: build
+
+help:
+ @echo "Targets: meta build wheel sdist test test-services install install-user man clean release"
+ @echo "Version: $(VERSION) commit: $(GIT_COMMIT) built: $(BUILD_DATE)"
+
+version: meta
+ @$(PYTHON) -c "from rns_filesync._meta import version_string; print(version_string())"
+
+meta:
+ VERSION="$(VERSION)" BUILD_DATE="$(BUILD_DATE)" GIT_COMMIT="$(GIT_COMMIT)" GIT_DIRTY="$(GIT_DIRTY)" \
+ $(PYTHON) scripts/bake_meta.py
+
+clean:
+ rm -rf dist/ build/ *.egg-info .pytest_cache .coverage htmlcov
+ find . -type d -name __pycache__ -prune -exec rm -rf {} +
+
+build: meta wheel sdist
+
+wheel: meta
+ $(PYTHON) -m build --wheel
+
+sdist: meta
+ $(PYTHON) -m build --sdist
+
+sign: build
+ for f in dist/*.tar.gz dist/*.whl; do \
+ rnid -f -i $(RNID_KEY) -s "$$f" -w "$$f.rsg"; \
+ done
+
+upload: sign
+ twine upload dist/*.whl dist/*.tar.gz
+
+release: tag release-rns
+
+release-rns: sign
+ mkdir -p dist
+ RELEASE_TAG=$(TAG) EDITOR="$(EDITOR)" \
+ rngit release -i $(RNID_KEY) $(RNS_REMOTE) create $(TAG):./dist
+
+tag:
+ @if git rev-parse -q --verify "refs/tags/$(TAG)" >/dev/null 2>&1; then \
+ echo "Tag $(TAG) already exists. Use: make retag TAG=$(TAG)"; \
+ echo "Or republish rngit only: make release-rns TAG=$(TAG)"; \
+ exit 1; \
+ fi
+ git tag -s $(TAG) -m "$(TAG)"
+ git push origin $(TAG)
+
+retag:
+ git tag -d $(TAG) 2>/dev/null || true
+ -git push origin :refs/tags/$(TAG) 2>/dev/null || true
+ git tag -s $(TAG) -m "$(TAG)"
+ git push origin $(TAG)
+
+test:
+ $(PYTHON) -m pytest -m "not exploratory and not live"
+
+test-live:
+ $(PYTHON) -m pytest -m live
+
+test-services:
+ ./packaging/docker-test/run-all.sh
+
+test-bwrap:
+ ./packaging/docker-test/test-bwrap.sh
+
+install: meta
+ $(PIP) install --break-system-packages .
+ $(MAKE) man
+
+install-user: meta
+ $(PIP) install --user .
+ mkdir -p $(HOME)/.local/share/man/man1
+ cp man/man1/rns-filesync.1 $(HOME)/.local/share/man/man1/
+
+man:
+ mkdir -p $(DESTDIR)$(PREFIX)/share/man/man1
+ cp man/man1/rns-filesync.1 $(DESTDIR)$(PREFIX)/share/man/man1/
+
+uninstall:
+ $(PIP) uninstall -y rns-filesync || true
+ rm -f $(DESTDIR)$(PREFIX)/share/man/man1/rns-filesync.1
+ rm -f $(HOME)/.local/share/man/man1/rns-filesync.1

diff --git a/vendor/rns_filesync/README.md b/vendor/rns_filesync/README.md
new file mode 100644
index 00000000..8e3715b6
--- /dev/null
+++ b/vendor/rns_filesync/README.md
@@ -0,0 +1,273 @@
+# RNS FileSync
+
+Decentralized peer-to-peer file synchronization over the Reticulum Network Stack.
+
+This package is designed as an embeddable library for host programs such as
+MeshChatX and Sideband, with a thin CLI for standalone use. There is no TUI.
+
+For usability and easy usage this follows rngit permissions and configuration.
+
+## Features
+
+- Peer-to-peer sync with no central server
+- Delta sync for changed blocks
+- Path-jailed writes and rngit-style ACL (permission:target)
+- Embed-safe: reuses an existing RNS.Reticulum instance when present
+- Works over any Reticulum medium (radio, LoRa, WiFi, internet)
+- Daemon mode via --no-repl for init systems and containers
+
+## Install
+
+Over Reticulum with pip-rns (no internet required once you have a path):
+
+```bash
+pip-rns install rns://06a54b505bb67b25ef3f8097e8001edc/public/rns-filesync --pipx
+```
+
+From a release wheel over Reticulum (no source build):
+
+```bash
+pip-rns install --from-release rns://06a54b505bb67b25ef3f8097e8001edc/public/rns-filesync --ref v1.0.0 --pipx
+```
+
+From GitHub with pipx:
+
+```bash
+pipx install git+https://github.com/Quad4-Software/RNS-Filesync
+```
+
+From a local wheel:
+
+```bash
+make wheel
+pipx install dist/rns_filesync-*.whl
+# or: pip install dist/rns_filesync-*.whl
+```
+
+From a local checkout (development):
+
+```bash
+pip install -e ".[dev]"
+# or: make install-user
+```
+
+Requires rns>=1.3.9. Current package version: **1.0.0**.
+
+```bash
+rns-filesync -v
+# rns-filesync 1.0.0 | built 2026-07-19T12:00:00Z | commit abc1234
+```
+
+## Config
+
+FileSync uses an rngit-like config directory (default `~/.rns_filesync/`):
+
+```bash
+rns-filesync -d ~/shared
+# creates ~/.rns_filesync/config on first run
+```
+
+| Flag | Meaning |
+|------|---------|
+| `--config DIR` | FileSync config directory (default `~/.rns_filesync`) |
+| `--rnsconfig DIR` | Reticulum config directory (default `~/.reticulum`) |
+| `-d DIR` | Sync directory (overrides `[filesync] directory`) |
+
+Example `~/.rns_filesync/config`:
+
+```ini
+[filesync]
+announce_interval = 300
+# directory = ~/shared
+identity = rns_filesync
+# peers = 9710b86ba12c42d1d8f30f74fe509286
+# blocked_identities = d31aeea49873006f13b3415520666a4e
+
+[aliases]
+# alice = d09285e660cfe27cee6d9a0beb58b7e0
+
+[access]
+# sync = r:all, w:9710b86ba12c42d1d8f30f74fe509286, d:9710b86ba12c42d1d8f30f74fe509286
+
+[logging]
+loglevel = 4
+```
+
+Identities are stored under `~/.rns_filesync/identities/`.
+
+## Permissions
+
+Same rule form as rngit: `permission:target`.
+
+| Perm | Meaning |
+|------|---------|
+| `r` | read |
+| `w` | write |
+| `d` | delete |
+| `rw` | read/write |
+| `rwd` | read/write/delete |
+| `adm` | admin (all) |
+
+Targets: identity hash, alias, `all`, or `none`.
+
+When any ACL rule is loaded, access is **deny by default**. With no rules configured, the peer stays open (library/embed default).
+
+Sidecar `.allowed` files (first found wins alongside config `[access]`):
+
+- `<sync_dir>.allowed`
+- `<parent>/<name>.allowed`
+- `<sync_dir>/.allowed`
+
+See `permissions.example`.
+
+```bash
+rns-filesync -d ~/shared --allow r:all --allow w:9710b86ba12c42d1d8f30f74fe509286
+```
+
+## CLI
+
+```bash
+rns-filesync -d ~/shared
+rns-filesync -d ~/shared -p <peer_identity_hash>
+rns-filesync --config ~/.rns_filesync --rnsconfig ~/.reticulum -d ~/shared
+rns-filesync -v
+rns-filesync --version
+```
+
+`-v` / `--version` prints version, bake build date, and git commit when available.
+
+`-p` expects a peer **identity hash**. A destination hash is accepted as fallback
+after the peer has announced.
+
+Interactive commands: `status`, `peers`, `files`, `connect`, `disconnect`,
+`browse`, `download`, `announce`, `quit`.
+
+Logging verbosity uses `--verbose` (repeatable). Quiet mode is `-q`.
+
+## Man page
+
+```bash
+make install-user # installs man page to ~/.local/share/man/man1/
+man rns-filesync
+```
+
+## Library embed (MeshChatX-style)
+
+```python
+from rns_filesync import FileSyncService
+from rns_filesync.permissions import PermissionStore
+
+perms = PermissionStore()
+perms.add_rule("r:all")
+perms.add_rule("w:9710b86ba12c42d1d8f30f74fe509286")
+
+service = FileSyncService(
+ identity=host_identity,
+ sync_directory="/path/to/sync",
+ reticulum=existing_reticulum,
+ permissions=perms,
+)
+dest_hash = service.start(monitor=True)
+service.connect_peer(peer_identity_hash)
+service.stop()
+```
+
+Keep aspect `rns_filesync.filesync` for wire compatibility.
+
+## Sideband plugin
+
+Install `rns-filesync` into the same Python environment Sideband uses, then copy
+the drop-in plugins from `sideband/` into Sideband's plugins directory and
+enable service/command plugins in Sideband settings.
+
+```bash
+# After installing rns-filesync (pip / pipx / pip-rns):
+cp sideband/rns_filesync_service.py ~/.config/sideband/plugins/
+cp sideband/rns_filesync_command.py ~/.config/sideband/plugins/
+```
+
+Set Sideband's plugin path to that directory, enable **service plugins** and
+**command plugins**, then restart Sideband.
+
+The service plugin reuses Sideband's identity and Reticulum instance. Sync
+directory, peers, and ACL come from `~/.rns_filesync/config`. If no directory
+is set there, it defaults to `~/.config/sideband/filesync`.
+
+Remote LXMF commands (from a peer allowed to run Sideband commands):
+
+```text
+filesync status
+filesync peers
+filesync files
+filesync announce
+filesync connect <identity_hash>
+filesync disconnect <peer_id>
+```
+
+## Daemon and packaging
+
+Init units (non-root) live under packaging/ for systemd (system and user),
+OpenRC, dinit, and runit. See packaging/README.md.
+
+```bash
+# system (dedicated rns-filesync user, hardened)
+sudo systemctl enable --now rns-filesync
+
+# or per-user
+systemctl --user enable --now rns-filesync
+```
+
+Daemon entrypoint:
+
+```bash
+rns-filesync -d ~/shared --no-repl -q
+```
+
+Bubblewrap sandbox example (full command in packaging/README.md):
+
+```bash
+DATA="${XDG_DATA_HOME:-$HOME/.local/share}/rns-filesync-sandbox"
+mkdir -p "$DATA/config" "$DATA/sync" "$DATA/reticulum"
+
+exec bwrap \
+ --die-with-parent \
+ --new-session \
+ --proc /proc \
+ --dev /dev \
+ --ro-bind / / \
+ --tmpfs /tmp \
+ --bind "$DATA" "$DATA" \
+ --uid "$(id -u)" --gid "$(id -g)" \
+ rns-filesync \
+ --config "$DATA/config" \
+ --rnsconfig "$DATA/reticulum" \
+ -d "$DATA/sync" \
+ --no-repl \
+ -q
+```
+
+## Docker
+
+Rootless multi-stage image and wheel builder under docker/. See docker/README.md.
+
+```bash
+docker build -f docker/Dockerfile -t rns-filesync:1.0.0 .
+```
+
+## Tests
+
+```bash
+make test
+pytest -m "not live and not exploratory"
+pytest -m e2e
+pytest -m live
+```
+
+Markers: `unit`, `acceptance`, `smoke`, `e2e`, `fuzz`, `live`, `exploratory`.
+
+Useful Make targets (aligned with pip-rns): `meta`, `wheel`, `sdist`, `test`,
+`install-user`, `man`, `sign`, `release-rns`, `clean`, `test-services`.
+
+## License
+
+BSD-2-Clause. See LICENSE.

diff --git a/vendor/rns_filesync/README_RU.md b/vendor/rns_filesync/README_RU.md
new file mode 100644
index 00000000..e9ea2834
--- /dev/null
+++ b/vendor/rns_filesync/README_RU.md
@@ -0,0 +1,12 @@
+# RNS FileSync (RU)
+
+English README is the source of truth: [README.md](README.md).
+
+Кратко: библиотека и CLI для синхронизации каталогов через Reticulum (`rns>=1.3.9`).
+Конфиг и ACL как у rngit: `~/.rns_filesync/config`, правила `r:hash` / `w:all`, файлы `.allowed`.
+
+```bash
+pip install -e ".[dev]"
+rns-filesync -d ~/shared -p <identity_hash>
+rns-filesync --config ~/.rns_filesync --rnsconfig ~/.reticulum -d ~/shared
+```

diff --git a/vendor/rns_filesync/docker/Dockerfile b/vendor/rns_filesync/docker/Dockerfile
new file mode 100644
index 00000000..c19b7044
--- /dev/null
+++ b/vendor/rns_filesync/docker/Dockerfile
@@ -0,0 +1,46 @@
+# Multi-stage rootless runtime image for rns-filesync.
+# Build: docker build -f docker/Dockerfile -t rns-filesync:1.0.0 .
+# Run instructions: see docker/README.md
+
+ARG PYTHON_VERSION=3.12
+
+FROM python:${PYTHON_VERSION}-alpine AS builder
+
+WORKDIR /build
+
+RUN apk add --no-cache gcc musl-dev libffi-dev openssl-dev
+
+COPY pyproject.toml README.md LICENSE ./
+COPY rns_filesync ./rns_filesync
+COPY scripts ./scripts
+COPY man ./man
+
+RUN python -m pip install --no-cache-dir --upgrade pip build \
+ && python scripts/bake_meta.py \
+ && python -m build --wheel \
+ && python -m venv /opt/venv \
+ && /opt/venv/bin/pip install --no-cache-dir dist/*.whl \
+ && /opt/venv/bin/pip uninstall -y pip setuptools wheel \
+ && find /opt/venv -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
+
+FROM python:${PYTHON_VERSION}-alpine AS runtime
+
+RUN apk upgrade --no-cache \
+ && adduser -D -u 1000 -h /home/filesync filesync \
+ && mkdir -p /config /data /reticulum \
+ && chown -R filesync:filesync /config /data /reticulum /home/filesync
+
+COPY --from=builder /opt/venv /opt/venv
+
+ENV PATH=/opt/venv/bin:$PATH \
+ HOME=/home/filesync \
+ PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1
+
+USER filesync
+WORKDIR /home/filesync
+
+VOLUME ["/config", "/data", "/reticulum"]
+
+ENTRYPOINT ["rns-filesync"]
+CMD ["--config", "/config", "--rnsconfig", "/reticulum", "-d", "/data", "--no-repl", "-q"]

diff --git a/vendor/rns_filesync/docker/Dockerfile.build b/vendor/rns_filesync/docker/Dockerfile.build
new file mode 100644
index 00000000..3c02ea56
--- /dev/null
+++ b/vendor/rns_filesync/docker/Dockerfile.build
@@ -0,0 +1,29 @@
+# Wheel builder image. Writes .whl artifacts to /output.
+# Build and extract:
+# docker build -f docker/Dockerfile.build -t rns-filesync-build .
+# docker run --rm -v "$PWD/dist:/output" rns-filesync-build
+
+ARG PYTHON_VERSION=3.12
+
+FROM python:${PYTHON_VERSION}-alpine
+
+WORKDIR /build
+
+RUN apk add --no-cache gcc musl-dev libffi-dev openssl-dev git \
+ && adduser -D -u 1000 builder \
+ && mkdir -p /output \
+ && chown builder:builder /output /build
+
+COPY --chown=builder:builder pyproject.toml README.md LICENSE ./
+COPY --chown=builder:builder rns_filesync ./rns_filesync
+COPY --chown=builder:builder scripts ./scripts
+COPY --chown=builder:builder man ./man
+
+USER builder
+
+RUN python -m pip install --user --no-cache-dir --upgrade pip build \
+ && python scripts/bake_meta.py \
+ && python -m build --wheel --outdir /output \
+ && ls -la /output
+
+CMD ["sh", "-c", "cp -a /output/. /dist/ 2>/dev/null || true; ls -la /output"]

diff --git a/vendor/rns_filesync/docker/README.md b/vendor/rns_filesync/docker/README.md
new file mode 100644
index 00000000..446424f1
--- /dev/null
+++ b/vendor/rns_filesync/docker/README.md
@@ -0,0 +1,84 @@
+# Docker images for RNS FileSync
+
+Rootless multi-stage runtime and a separate wheel builder.
+
+## Runtime image (rootless)
+
+Build:
+
+```bash
+docker build -f docker/Dockerfile -t rns-filesync:1.0.0 .
+```
+
+Prepare host dirs owned by uid 1000:
+
+```bash
+mkdir -p ./fs-config ./fs-data ./fs-reticulum
+```
+
+Put FileSync config under ./fs-config (config file named config),
+Reticulum config under ./fs-reticulum, and the sync tree under ./fs-data.
+
+Run (rootless-friendly, drop capabilities):
+
+```bash
+docker run --rm -d \
+ --name rns-filesync \
+ --user=1000:1000 \
+ --read-only \
+ --tmpfs /tmp:rw,size=64m \
+ --security-opt no-new-privileges \
+ --cap-drop ALL \
+ -v "$PWD/fs-config:/config:rw" \
+ -v "$PWD/fs-data:/data:rw" \
+ -v "$PWD/fs-reticulum:/reticulum:rw" \
+ rns-filesync:1.0.0
+```
+
+Override peers or flags:
+
+```bash
+docker run --rm --user=1000:1000 \
+ -v "$PWD/fs-config:/config" \
+ -v "$PWD/fs-data:/data" \
+ -v "$PWD/fs-reticulum:/reticulum" \
+ rns-filesync:1.0.0 \
+ --config /config --rnsconfig /reticulum -d /data --no-repl -p PEER_HASH
+```
+
+Check version:
+
+```bash
+docker run --rm rns-filesync:1.0.0 -v
+```
+
+## Wheel builder
+
+Build wheels inside the image and copy them out:
+
+```bash
+mkdir -p dist
+docker build -f docker/Dockerfile.build -t rns-filesync-build .
+docker run --rm -v "$PWD/dist:/output" rns-filesync-build \
+ sh -c 'cp -a /output/. /output/ 2>/dev/null; ls -la /output'
+```
+
+If the image already wrote wheels under /output during build, extract with:
+
+```bash
+cid=$(docker create rns-filesync-build)
+docker cp "$cid":/output/. ./dist/
+docker rm "$cid"
+```
+
+Install a built wheel:
+
+```bash
+pipx install dist/rns_filesync-*.whl
+```
+
+## Notes
+
+- The runtime user is filesync (uid 1000). Match host volume ownership.
+- Network is required for Reticulum interfaces you enable in /reticulum/config.
+- Prefer --no-repl for containers and service managers.

diff --git a/vendor/rns_filesync/man/man1/rns-filesync.1 b/vendor/rns_filesync/man/man1/rns-filesync.1
new file mode 100644
index 00000000..df4c95e1
--- /dev/null
+++ b/vendor/rns_filesync/man/man1/rns-filesync.1
@@ -0,0 +1,128 @@
+.\" Man page for rns-filesync
+.\" vim: ft=man
+.TH RNS-FILESYNC 1 "2026-07-19" "1.0.0" "User Commands"
+.SH NAME
+rns-filesync \- peer-to-peer directory sync over the Reticulum Network Stack
+.SH SYNOPSIS
+.B rns-filesync
+[\fB-v\fR|\fB--version\fR]
+[\fB--config\fR \fIDIR\fR]
+[\fB--rnsconfig\fR \fIDIR\fR]
+[\fB-d\fR \fIDIR\fR]
+[\fB-i\fR \fINAME\fR]
+[\fB-p\fR \fIHASH\fR]...
+[\fB-n\fR]
+[\fB-a\fR \fISECONDS\fR]
+[\fB--allowed\fR \fIFILE\fR]
+[\fB--allow\fR \fIRULE\fR]...
+[\fB--perms\fR \fIPERMS\fR]
+[\fB--verbose\fR]...
+[\fB-q\fR]
+[\fB--no-repl\fR]
+.SH DESCRIPTION
+.B rns-filesync
+synchronizes a directory with peers on the Reticulum network.
+It is an embeddable library (MeshChatX, Sideband) with a thin CLI.
+There is no TUI.
+.PP
+Configuration and ACL follow an rngit-like layout under
+.I ~/.rns_filesync/
+by default.
+.SH OPTIONS
+.TP
+.BR \-v ", " \-\-version
+Print version, build date, and git commit (when baked), then exit.
+.TP
+.BI \-\-config " DIR"
+FileSync config directory (default:
+.IR ~/.rns_filesync ).
+.TP
+.BI \-\-rnsconfig " DIR"
+Reticulum config directory (default:
+.IR ~/.reticulum ).
+.TP
+.BR \-d ", " \-\-directory " " \fIDIR\fR
+Directory to synchronize (overrides
+.B [filesync] directory
+in config).
+.TP
+.BR \-i ", " \-\-identity " " \fINAME\fR
+Identity name stored under the FileSync config directory.
+.TP
+.BR \-p ", " \-\-peer " " \fIHASH\fR
+Peer identity hash to connect on start (repeatable).
+A destination hash is accepted as fallback after the peer has announced.
+.TP
+.BR \-n ", " \-\-no\-monitor
+Disable filesystem monitoring.
+.TP
+.BR \-a ", " \-\-announce\-interval " " \fISECONDS\fR
+Announce interval in seconds.
+.TP
+.BI \-\-allowed " FILE"
+Path to an
+.B .allowed
+ACL file (rngit-style
+.I permission:target
+lines).
+.TP
+.BI \-\-allow " RULE"
+ACL rule such as
+.I r:all
+or
+.IR w:HASH
+(repeatable).
+.TP
+.BI \-\-perms " PERMS"
+Legacy shorthand for bare
+.B \-\-allow
+hashes (
+.IR r ,
+.IR w ,
+.IR d ,
+.IR rw ,
+.IR rwd ).
+.TP
+.B \-\-verbose
+Increase Reticulum log verbosity (repeatable).
+.TP
+.BR \-q ", " \-\-quiet
+Reduce logging.
+.TP
+.B \-\-no\-repl
+Run without the interactive command prompt.
+.SH INTERACTIVE COMMANDS
+When the REPL is enabled:
+.BR status ,
+.BR peers ,
+.BR files ,
+.BR connect ,
+.BR disconnect ,
+.BR browse ,
+.BR download ,
+.BR announce ,
+.BR quit .
+.SH FILES
+.TP
+.I ~/.rns_filesync/config
+Main FileSync configuration (ConfigObj).
+.TP
+.I ~/.rns_filesync/identities/
+Stored identities.
+.TP
+.I <sync_dir>.allowed
+Optional sidecar ACL next to the sync directory.
+.SH EXAMPLES
+.nf
+rns-filesync -d ~/shared
+rns-filesync -d ~/shared -p 9710b86ba12c42d1d8f30f74fe509286
+rns-filesync -v
+.fi
+.SH SEE ALSO
+.BR rnsd (1),
+.BR rnstatus (1)
+.PP
+Documentation and Sideband plugins:
+.I https://github.com/Quad4-Software/RNS-Filesync
+.SH AUTHOR
+Quad4 Software

diff --git a/vendor/rns_filesync/packaging/README.md b/vendor/rns_filesync/packaging/README.md
new file mode 100644
index 00000000..096c9be6
--- /dev/null
+++ b/vendor/rns_filesync/packaging/README.md
@@ -0,0 +1,126 @@
+# Init and packaging files for RNS FileSync
+
+All system units run as non-root user rns-filesync with state under
+/var/lib/rns-filesync. Prefer the systemd user unit when you do not want a
+system account.
+
+## Layout
+
+- systemd/rns-filesync.service - hardened system unit
+- systemd/rns-filesync.user.service - per-user unit (systemctl --user)
+- openrc/rns-filesync - OpenRC script
+- dinit/rns-filesync - dinit service
+- runit/rns-filesync/run - runit run script
+- sysusers.d/rns-filesync.conf - system user
+- tmpfiles.d/rns-filesync.conf - state directories
+- rns-filesync.env.example - optional env file
+
+## systemd (system)
+
+```bash
+sudo install -m 644 packaging/sysusers.d/rns-filesync.conf /usr/lib/sysusers.d/
+sudo install -m 644 packaging/tmpfiles.d/rns-filesync.conf /usr/lib/tmpfiles.d/
+sudo systemd-sysusers
+sudo systemd-tmpfiles --create
+sudo install -m 644 packaging/systemd/rns-filesync.service /etc/systemd/system/
+sudo systemctl daemon-reload
+sudo systemctl enable --now rns-filesync
+```
+
+Ensure /usr/bin/rns-filesync exists (package install or symlink).
+
+## systemd (user)
+
+```bash
+mkdir -p ~/.config/systemd/user ~/Filesync
+cp packaging/systemd/rns-filesync.user.service ~/.config/systemd/user/rns-filesync.service
+systemctl --user daemon-reload
+systemctl --user enable --now rns-filesync
+```
+
+Adjust ExecStart paths if rns-filesync is not in ~/.local/bin.
+
+## OpenRC
+
+```bash
+sudo install -m 755 packaging/openrc/rns-filesync /etc/init.d/rns-filesync
+sudo rc-update add rns-filesync default
+sudo rc-service rns-filesync start
+```
+
+Create user rns-filesync first if your distro does not use sysusers.
+
+## dinit
+
+```bash
+sudo install -m 644 packaging/dinit/rns-filesync /etc/dinit.d/rns-filesync
+sudo dinitctl enable rns-filesync
+```
+
+## runit
+
+```bash
+sudo mkdir -p /etc/sv/rns-filesync
+sudo install -m 755 packaging/runit/rns-filesync/run /etc/sv/rns-filesync/run
+sudo ln -s /etc/sv/rns-filesync /var/service/
+```
+
+## Security notes
+
+System units use NoNewPrivileges, ProtectSystem=strict, empty capability set,
+and ReadWritePaths limited to /var/lib/rns-filesync. Sync ACL still comes from
+~/.rns_filesync style config under that state directory.
+
+## Sandboxing
+
+For an extra filesystem boundary on a host install (pip, pipx, or package),
+run under Bubblewrap. Network stays shared so Reticulum interfaces keep working.
+Do not add --unshare-net unless you intend to isolate networking.
+
+```bash
+DATA="${XDG_DATA_HOME:-$HOME/.local/share}/rns-filesync-sandbox"
+mkdir -p "$DATA/config" "$DATA/sync" "$DATA/reticulum"
+
+exec bwrap \
+ --die-with-parent \
+ --new-session \
+ --proc /proc \
+ --dev /dev \
+ --ro-bind / / \
+ --tmpfs /tmp \
+ --bind "$DATA" "$DATA" \
+ --uid "$(id -u)" --gid "$(id -g)" \
+ rns-filesync \
+ --config "$DATA/config" \
+ --rnsconfig "$DATA/reticulum" \
+ -d "$DATA/sync" \
+ --no-repl \
+ -q
+```
+
+Notes:
+
+- Only "$DATA" is writable. The rest of the root filesystem is read-only.
+- Put config, sync tree, and Reticulum config under "$DATA" so the process
+ does not need write access to your home directory.
+- If rns-filesync lives in a venv outside "$DATA", --ro-bind / / still allows
+ reading it. Bind that path read-write only if the venv must be mutated.
+- For USB radios, device nodes under /dev are available via --dev /dev.
+ Tighten further if your policy requires it.
+- Docker and Podman are a separate isolation model. See docker/README.md.
+
+## Service file tests (Docker)
+
+Run packaging unit tests against appropriate OS images:
+
+| Init | Image |
+|------|-------|
+| systemd | debian:bookworm-slim (+ systemd) |
+| OpenRC | alpine:3.21 |
+| dinit | debian:bookworm-slim (service file + command) |
+| runit | Void Linux (Alpine fallback) |
+
+```bash
+./packaging/docker-test/run-all.sh
+# or: make test-services
+```

diff --git a/vendor/rns_filesync/packaging/dinit/rns-filesync b/vendor/rns_filesync/packaging/dinit/rns-filesync
new file mode 100644
index 00000000..db7a9963
--- /dev/null
+++ b/vendor/rns_filesync/packaging/dinit/rns-filesync
@@ -0,0 +1,9 @@
+# dinit service for rns-filesync (non-root).
+# Install to /etc/dinit.d/rns-filesync
+# Create user rns-filesync and state dirs under /var/lib/rns-filesync first.
+
+type = process
+command = /usr/bin/env HOME=/var/lib/rns-filesync /usr/bin/rns-filesync --config /var/lib/rns-filesync/config --rnsconfig /var/lib/rns-filesync/reticulum -d /var/lib/rns-filesync/sync --no-repl -q
+restart = true
+smooth-recovery = true
+runs-as = rns-filesync

diff --git a/vendor/rns_filesync/packaging/docker-test/Dockerfile.dinit b/vendor/rns_filesync/packaging/docker-test/Dockerfile.dinit
new file mode 100644
index 00000000..32b95c95
--- /dev/null
+++ b/vendor/rns_filesync/packaging/docker-test/Dockerfile.dinit
@@ -0,0 +1,11 @@
+# Debian for dinit service-file validation and non-root command execution.
+# Full dinit as PID1 is not required to verify the shipped service definition.
+FROM debian:bookworm-slim
+
+ENV DEBIAN_FRONTEND=noninteractive
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ python3 python3-pip ca-certificates procps \
+ && rm -rf /var/lib/apt/lists/*
+
+CMD ["/bin/sh"]

diff --git a/vendor/rns_filesync/packaging/docker-test/Dockerfile.openrc b/vendor/rns_filesync/packaging/docker-test/Dockerfile.openrc
new file mode 100644
index 00000000..0ec758c2
--- /dev/null
+++ b/vendor/rns_filesync/packaging/docker-test/Dockerfile.openrc
@@ -0,0 +1,9 @@
+# Alpine + OpenRC: validate and start via openrc-run style command path.
+FROM alpine:3.21
+
+RUN apk add --no-cache \
+ openrc python3 py3-pip procps shadow \
+ && mkdir -p /run/openrc \
+ && touch /run/openrc/softlevel
+
+CMD ["/bin/sh"]

diff --git a/vendor/rns_filesync/packaging/docker-test/Dockerfile.runit b/vendor/rns_filesync/packaging/docker-test/Dockerfile.runit
new file mode 100644
index 00000000..5d05fc75
--- /dev/null
+++ b/vendor/rns_filesync/packaging/docker-test/Dockerfile.runit
@@ -0,0 +1,8 @@
+# Void Linux + runit.
+FROM ghcr.io/void-linux/void-glibc:latest
+
+RUN xbps-install -Syu \
+ && xbps-install -Sy python3 python3-pip runit procps-ng shadow \
+ && xbps-remove -yO || true
+
+CMD ["/bin/sh"]

diff --git a/vendor/rns_filesync/packaging/docker-test/Dockerfile.runit-alpine b/vendor/rns_filesync/packaging/docker-test/Dockerfile.runit-alpine
new file mode 100644
index 00000000..a69e1866
--- /dev/null
+++ b/vendor/rns_filesync/packaging/docker-test/Dockerfile.runit-alpine
@@ -0,0 +1,8 @@
+# Alpine fallback when Void image cannot be pulled.
+FROM alpine:3.21
+
+RUN apk add --no-cache \
+ runit python3 py3-pip procps shadow \
+ runit-openrc 2>/dev/null || apk add --no-cache runit python3 py3-pip procps shadow
+
+CMD ["/bin/sh"]

diff --git a/vendor/rns_filesync/packaging/docker-test/Dockerfile.systemd b/vendor/rns_filesync/packaging/docker-test/Dockerfile.systemd
new file mode 100644
index 00000000..2707b04a
--- /dev/null
+++ b/vendor/rns_filesync/packaging/docker-test/Dockerfile.systemd
@@ -0,0 +1,12 @@
+# Debian Bookworm + systemd: verify unit and start via systemd.
+FROM debian:bookworm-slim
+
+ENV DEBIAN_FRONTEND=noninteractive
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ systemd systemd-sysv python3 python3-pip python3-venv \
+ ca-certificates procps \
+ && rm -rf /var/lib/apt/lists/*
+
+STOPSIGNAL SIGRTMIN+3
+CMD ["/lib/systemd/systemd"]

diff --git a/vendor/rns_filesync/packaging/docker-test/assert-running.sh b/vendor/rns_filesync/packaging/docker-test/assert-running.sh
new file mode 100755
index 00000000..71a1be00
--- /dev/null
+++ b/vendor/rns_filesync/packaging/docker-test/assert-running.sh
@@ -0,0 +1,22 @@
+#!/bin/sh
+# Assert daemon is alive as rns-filesync for a few seconds.
+set -eu
+pid="$1"
+user="${2:-rns-filesync}"
+i=0
+while [ "$i" -lt 8 ]; do
+ if ! kill -0 "$pid" 2>/dev/null; then
+ echo "FAIL: process $pid died"
+ exit 1
+ fi
+ owner="$(ps -o user= -p "$pid" 2>/dev/null | tr -d ' ' || true)"
+ if [ -n "$owner" ] && [ "$owner" != "$user" ]; then
+ echo "FAIL: pid $pid owner is '$owner' expected '$user'"
+ exit 1
+ fi
+ i=$((i + 1))
+ sleep 1
+done
+echo "OK: pid $pid running as $user for ${i}s"
+kill "$pid" 2>/dev/null || true
+wait "$pid" 2>/dev/null || true

diff --git a/vendor/rns_filesync/packaging/docker-test/common-setup.sh b/vendor/rns_filesync/packaging/docker-test/common-setup.sh
new file mode 100755
index 00000000..7b88f484
--- /dev/null
+++ b/vendor/rns_filesync/packaging/docker-test/common-setup.sh
@@ -0,0 +1,88 @@
+#!/bin/sh
+# Shared setup for packaging service tests inside containers.
+set -eu
+
+pip_install() {
+ if python3 -m pip install --help 2>/dev/null | grep -q break-system-packages; then
+ python3 -m pip install --break-system-packages "$@"
+ else
+ python3 -m pip install "$@"
+ fi
+}
+
+echo "==> install rns-filesync from wheel"
+pip_install /wheels/*.whl
+BIN="$(command -v rns-filesync)"
+case "$BIN" in
+ /usr/bin/rns-filesync) ;;
+ *) ln -sfn "$BIN" /usr/bin/rns-filesync ;;
+esac
+test -x /usr/bin/rns-filesync
+/usr/bin/rns-filesync -v
+
+echo "==> create non-root user and state dirs"
+if ! id rns-filesync >/dev/null 2>&1; then
+ if command -v useradd >/dev/null 2>&1; then
+ useradd --system --home-dir /var/lib/rns-filesync --create-home \
+ --shell /usr/sbin/nologin rns-filesync 2>/dev/null \
+ || useradd -r -d /var/lib/rns-filesync -m -s /sbin/nologin rns-filesync
+ elif command -v adduser >/dev/null 2>&1; then
+ adduser -S -D -h /var/lib/rns-filesync -s /sbin/nologin rns-filesync 2>/dev/null \
+ || adduser --system --home /var/lib/rns-filesync --shell /usr/sbin/nologin rns-filesync
+ else
+ echo "FAIL: no useradd/adduser"
+ exit 1
+ fi
+fi
+
+# Ensure group exists for chown -g
+if ! getent group rns-filesync >/dev/null 2>&1; then
+ if command -v groupadd >/dev/null 2>&1; then
+ groupadd --system rns-filesync 2>/dev/null || groupadd rns-filesync
+ elif command -v addgroup >/dev/null 2>&1; then
+ addgroup -S rns-filesync 2>/dev/null || addgroup rns-filesync
+ fi
+fi
+
+install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync
+install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/config
+install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/reticulum
+install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/sync
+
+cat >/var/lib/rns-filesync/config/config <<'EOF'
+[filesync]
+announce_interval = 300
+directory = /var/lib/rns-filesync/sync
+identity = rns_filesync
+
+[access]
+sync = r:all
+
+[logging]
+loglevel = 6
+EOF
+
+cat >/var/lib/rns-filesync/reticulum/config <<'EOF'
+[reticulum]
+ enable_transport = Yes
+ share_instance = No
+ shared_instance_port = 37428
+ instance_name = filesync_svc_test
+ panic_on_interface_error = No
+
+[logging]
+ loglevel = 6
+
+[interfaces]
+ [[Loopback UDP]]
+ type = UDPInterface
+ enabled = yes
+ listen_ip = 127.0.0.1
+ listen_port = 42500
+ forward_ip = 127.0.0.1
+ forward_port = 42501
+EOF
+
+chown -R rns-filesync:rns-filesync /var/lib/rns-filesync
+
+echo "==> shared setup done"

diff --git a/vendor/rns_filesync/packaging/docker-test/run-all.sh b/vendor/rns_filesync/packaging/docker-test/run-all.sh
new file mode 100755
index 00000000..379587f3
--- /dev/null
+++ b/vendor/rns_filesync/packaging/docker-test/run-all.sh
@@ -0,0 +1,84 @@
+#!/bin/sh
+# Build wheels and run packaging service tests in appropriate OS images.
+set -eu
+ROOT="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
+cd "$ROOT"
+
+IMG_RUNIT="${IMG_RUNIT:-ghcr.io/void-linux/void-glibc-full:20240526R1}"
+
+echo "==> bake wheel"
+mkdir -p dist
+make wheel
+WHEEL="$(ls -1 dist/rns_filesync-*.whl | head -n1)"
+test -n "$WHEEL"
+echo "wheel=$WHEEL"
+
+run_named() {
+ name="$1"
+ dockerfile="$2"
+ base_hint="$3"
+ testscript="$4"
+ echo ""
+ echo "======== TEST $name ($base_hint) ========"
+ docker build -f "packaging/docker-test/$dockerfile" \
+ -t "rns-filesync-svc-$name" \
+ packaging/docker-test
+ docker run --rm \
+ -v "$ROOT/packaging:/packaging:ro" \
+ -v "$ROOT/packaging/docker-test:/test:ro" \
+ -v "$ROOT/dist:/wheels:ro" \
+ "rns-filesync-svc-$name" \
+ /bin/sh /test/"$testscript"
+}
+
+echo ""
+echo "======== TEST systemd (debian bookworm + systemd) ========"
+docker build -f packaging/docker-test/Dockerfile.systemd \
+ -t rns-filesync-svc-systemd packaging/docker-test
+
+cid="$(docker run -d --privileged --cgroupns=host \
+ -v /sys/fs/cgroup:/sys/fs/cgroup:rw \
+ -v "$ROOT/packaging:/packaging:ro" \
+ -v "$ROOT/packaging/docker-test:/test:ro" \
+ -v "$ROOT/dist:/wheels:ro" \
+ rns-filesync-svc-systemd)"
+cleanup_systemd() {
+ docker rm -f "$cid" >/dev/null 2>&1 || true
+}
+trap cleanup_systemd EXIT
+i=0
+while [ "$i" -lt 40 ]; do
+ if docker exec "$cid" systemctl list-units >/dev/null 2>&1; then
+ break
+ fi
+ i=$((i + 1))
+ sleep 1
+done
+docker exec "$cid" /bin/sh /test/test-systemd.sh
+cleanup_systemd
+trap - EXIT
+echo "PASS: systemd outer"
+
+run_named openrc Dockerfile.openrc "alpine:3.21 + openrc" test-openrc.sh
+run_named dinit Dockerfile.dinit "debian:bookworm (dinit service file)" test-dinit.sh
+
+echo ""
+echo "======== TEST runit ========"
+if docker pull "$IMG_RUNIT" >/dev/null 2>&1 \
+ && docker build -f packaging/docker-test/Dockerfile.runit \
+ -t rns-filesync-svc-runit packaging/docker-test; then
+ echo "using Void Linux runit image"
+else
+ echo "using Alpine runit fallback"
+ docker build -f packaging/docker-test/Dockerfile.runit-alpine \
+ -t rns-filesync-svc-runit packaging/docker-test
+fi
+docker run --rm \
+ -v "$ROOT/packaging:/packaging:ro" \
+ -v "$ROOT/packaging/docker-test:/test:ro" \
+ -v "$ROOT/dist:/wheels:ro" \
+ rns-filesync-svc-runit \
+ /bin/sh /test/test-runit.sh
+
+echo ""
+echo "ALL SERVICE TESTS PASSED"

diff --git a/vendor/rns_filesync/packaging/docker-test/test-bwrap.sh b/vendor/rns_filesync/packaging/docker-test/test-bwrap.sh
new file mode 100755
index 00000000..af126506
--- /dev/null
+++ b/vendor/rns_filesync/packaging/docker-test/test-bwrap.sh
@@ -0,0 +1,118 @@
+#!/bin/sh
+# Exercise the documented Bubblewrap sandbox command on the host.
+set -eu
+
+ROOT="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
+if [ -x "$ROOT/.venv/bin/rns-filesync" ]; then
+ RNS_BIN="$ROOT/.venv/bin/rns-filesync"
+elif command -v rns-filesync >/dev/null 2>&1; then
+ RNS_BIN="$(command -v rns-filesync)"
+else
+ echo "FAIL: rns-filesync not found"
+ exit 1
+fi
+
+if ! command -v bwrap >/dev/null 2>&1; then
+ echo "FAIL: bwrap not installed (package bubblewrap)"
+ exit 1
+fi
+
+DATA="${XDG_DATA_HOME:-$HOME/.local/share}/rns-filesync-sandbox-test"
+rm -rf "$DATA"
+mkdir -p "$DATA/config" "$DATA/sync" "$DATA/reticulum"
+
+cat > "$DATA/config/config" <<EOF
+[filesync]
+announce_interval = 300
+directory = $DATA/sync
+identity = rns_filesync
+
+[access]
+sync = r:all
+
+[logging]
+loglevel = 6
+EOF
+
+cat > "$DATA/reticulum/config" <<'EOF'
+[reticulum]
+ enable_transport = Yes
+ share_instance = No
+ shared_instance_port = 37501
+ instance_name = filesync_bwrap_test
+ panic_on_interface_error = No
+
+[logging]
+ loglevel = 6
+
+[interfaces]
+ [[Loopback UDP]]
+ type = UDPInterface
+ enabled = yes
+ listen_ip = 127.0.0.1
+ listen_port = 42610
+ forward_ip = 127.0.0.1
+ forward_port = 42611
+EOF
+
+echo "==> version inside bwrap"
+bwrap \
+ --die-with-parent \
+ --new-session \
+ --proc /proc \
+ --dev /dev \
+ --ro-bind / / \
+ --tmpfs /tmp \
+ --bind "$DATA" "$DATA" \
+ --uid "$(id -u)" --gid "$(id -g)" \
+ "$RNS_BIN" --version
+
+echo "==> start sandboxed daemon (README command shape)"
+bwrap \
+ --die-with-parent \
+ --new-session \
+ --proc /proc \
+ --dev /dev \
+ --ro-bind / / \
+ --tmpfs /tmp \
+ --bind "$DATA" "$DATA" \
+ --uid "$(id -u)" --gid "$(id -g)" \
+ "$RNS_BIN" \
+ --config "$DATA/config" \
+ --rnsconfig "$DATA/reticulum" \
+ -d "$DATA/sync" \
+ --no-repl \
+ -q &
+BPID=$!
+sleep 3
+if ! kill -0 "$BPID" 2>/dev/null; then
+ echo "FAIL: bwrap daemon died"
+ wait "$BPID" || true
+ exit 1
+fi
+echo "OK: sandboxed daemon pid=$BPID alive"
+
+echo sandbox-write-ok > "$DATA/sync/probe.txt"
+test -f "$DATA/sync/probe.txt"
+echo "OK: DATA sync dir is writable"
+
+if bwrap \
+ --die-with-parent \
+ --new-session \
+ --proc /proc \
+ --dev /dev \
+ --ro-bind / / \
+ --tmpfs /tmp \
+ --bind "$DATA" "$DATA" \
+ --uid "$(id -u)" --gid "$(id -g)" \
+ /bin/sh -c 'echo should-fail > /etc/rns-filesync-bwrap-probe'; then
+ echo "FAIL: wrote outside DATA"
+ kill "$BPID" 2>/dev/null || true
+ exit 1
+fi
+echo "OK: root outside DATA is read-only"
+
+kill "$BPID" 2>/dev/null || true
+wait "$BPID" 2>/dev/null || true
+rm -rf "$DATA"
+echo "PASS: bwrap sandbox command"

diff --git a/vendor/rns_filesync/packaging/docker-test/test-dinit.sh b/vendor/rns_filesync/packaging/docker-test/test-dinit.sh
new file mode 100755
index 00000000..3d053924
--- /dev/null
+++ b/vendor/rns_filesync/packaging/docker-test/test-dinit.sh
@@ -0,0 +1,35 @@
+#!/bin/sh
+# Test dinit service file and the command it would run.
+set -eu
+. /test/common-setup.sh
+
+svc=/packaging/dinit/rns-filesync
+install -d /etc/dinit.d
+install -m 644 "$svc" /etc/dinit.d/rns-filesync
+
+echo "==> validate dinit service keys"
+grep -Eq '^type = process$' /etc/dinit.d/rns-filesync
+grep -Eq '^restart = true$' /etc/dinit.d/rns-filesync
+grep -Eq '^runs-as = rns-filesync$' /etc/dinit.d/rns-filesync
+grep -Eq '^command = .*/usr/bin/rns-filesync' /etc/dinit.d/rns-filesync
+
+if command -v dinitcheck >/dev/null 2>&1; then
+ echo "==> dinitcheck"
+ dinitcheck /etc/dinit.d/rns-filesync || dinitcheck -d /etc/dinit.d
+fi
+
+echo "==> run service command as rns-filesync"
+su -s /bin/sh -c \
+ 'exec /usr/bin/env HOME=/var/lib/rns-filesync /usr/bin/rns-filesync --config /var/lib/rns-filesync/config --rnsconfig /var/lib/rns-filesync/reticulum -d /var/lib/rns-filesync/sync --no-repl -q' \
+ rns-filesync &
+pid=$!
+sleep 1
+fs_pid="$(pgrep -u rns-filesync -f '/usr/bin/rns-filesync' | head -n1 || true)"
+if [ -z "$fs_pid" ]; then
+ echo "FAIL: rns-filesync not running as rns-filesync"
+ ps aux || true
+ exit 1
+fi
+/test/assert-running.sh "$fs_pid" rns-filesync
+
+echo "PASS: dinit"

diff --git a/vendor/rns_filesync/packaging/docker-test/test-openrc.sh b/vendor/rns_filesync/packaging/docker-test/test-openrc.sh
new file mode 100755
index 00000000..b0467b87
--- /dev/null
+++ b/vendor/rns_filesync/packaging/docker-test/test-openrc.sh
@@ -0,0 +1,35 @@
+#!/bin/sh
+# Test OpenRC service on Alpine.
+set -eu
+. /test/common-setup.sh
+
+install -m 755 /packaging/openrc/rns-filesync /etc/init.d/rns-filesync
+
+echo "==> shell syntax check"
+sh -n /etc/init.d/rns-filesync
+
+echo "==> openrc script metadata"
+grep -q 'command="/usr/bin/rns-filesync"' /etc/init.d/rns-filesync
+grep -q 'command_user="rns-filesync:rns-filesync"' /etc/init.d/rns-filesync
+grep -q 'command_background=yes' /etc/init.d/rns-filesync
+
+echo "==> mimic start_pre (checkpath dirs)"
+install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync
+install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/config
+install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/reticulum
+install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/sync
+
+echo "==> start daemon via openrc command + args as service user"
+su -s /bin/sh -c \
+ 'exec /usr/bin/rns-filesync --config /var/lib/rns-filesync/config --rnsconfig /var/lib/rns-filesync/reticulum -d /var/lib/rns-filesync/sync --no-repl -q' \
+ rns-filesync &
+sleep 1
+fs_pid="$(pgrep -u rns-filesync -f '/usr/bin/rns-filesync' | head -n1 || true)"
+if [ -z "$fs_pid" ]; then
+ echo "FAIL: rns-filesync not running as rns-filesync"
+ ps aux || true
+ exit 1
+fi
+/test/assert-running.sh "$fs_pid" rns-filesync
+
+echo "PASS: openrc"

diff --git a/vendor/rns_filesync/packaging/docker-test/test-runit.sh b/vendor/rns_filesync/packaging/docker-test/test-runit.sh
new file mode 100755
index 00000000..401c6df4
--- /dev/null
+++ b/vendor/rns_filesync/packaging/docker-test/test-runit.sh
@@ -0,0 +1,28 @@
+#!/bin/sh
+# Test runit run script on Void (or any runit-capable image).
+set -eu
+. /test/common-setup.sh
+
+install -d /etc/sv/rns-filesync
+install -m 755 /packaging/runit/rns-filesync/run /etc/sv/rns-filesync/run
+
+echo "==> shell syntax check"
+sh -n /etc/sv/rns-filesync/run
+
+echo "==> start via runit run script"
+# run script execs forever - start in background by replacing exec with run
+# Invoke under a subshell that keeps it as supervised child.
+/etc/sv/rns-filesync/run &
+pid=$!
+# The run script may re-exec as another pid via su/chpst. Find the filesync process.
+sleep 2
+fs_pid="$(pgrep -u rns-filesync -f '/usr/bin/rns-filesync' | head -n1 || true)"
+if [ -z "$fs_pid" ]; then
+ echo "FAIL: rns-filesync not running after runit start"
+ ps aux || true
+ exit 1
+fi
+/test/assert-running.sh "$fs_pid" rns-filesync
+kill "$pid" 2>/dev/null || true
+
+echo "PASS: runit"

diff --git a/vendor/rns_filesync/packaging/docker-test/test-systemd.sh b/vendor/rns_filesync/packaging/docker-test/test-systemd.sh
new file mode 100755
index 00000000..ceaa4e37
--- /dev/null
+++ b/vendor/rns_filesync/packaging/docker-test/test-systemd.sh
@@ -0,0 +1,51 @@
+#!/bin/sh
+# Test systemd unit inside a running systemd container.
+set -eu
+. /test/common-setup.sh
+
+install -m 644 /packaging/sysusers.d/rns-filesync.conf /usr/lib/sysusers.d/ 2>/dev/null || true
+install -m 644 /packaging/tmpfiles.d/rns-filesync.conf /usr/lib/tmpfiles.d/ 2>/dev/null || true
+systemd-sysusers || true
+systemd-tmpfiles --create || true
+
+install -m 644 /packaging/systemd/rns-filesync.service /etc/systemd/system/rns-filesync.service
+install -m 644 /packaging/systemd/rns-filesync.user.service /tmp/rns-filesync.user.service
+
+echo "==> systemd-analyze verify (system unit)"
+# Documentation=man: fails verify when man page is not installed in the image.
+cp /etc/systemd/system/rns-filesync.service /tmp/rns-filesync.verify.service
+sed -i '/^Documentation=/d' /tmp/rns-filesync.verify.service
+systemd-analyze verify /tmp/rns-filesync.verify.service
+
+echo "==> check user unit file parses"
+grep -q 'ExecStart=' /tmp/rns-filesync.user.service
+grep -q 'NoNewPrivileges=true' /tmp/rns-filesync.user.service
+
+echo "==> systemctl enable and start"
+systemctl daemon-reload
+systemctl enable rns-filesync.service
+systemctl start rns-filesync.service
+sleep 4
+systemctl --no-pager --full status rns-filesync.service || true
+if ! systemctl is-active --quiet rns-filesync.service; then
+ echo "WARN: systemctl start did not reach active (common in nested cgroup docker)"
+ echo "==> fallback: start ExecStart as service user"
+ su -s /bin/sh -c \
+ 'exec /usr/bin/rns-filesync --config /var/lib/rns-filesync/config --rnsconfig /var/lib/rns-filesync/reticulum -d /var/lib/rns-filesync/sync --no-repl -q' \
+ rns-filesync &
+ sleep 1
+ fs_pid="$(pgrep -u rns-filesync -f '/usr/bin/rns-filesync' | head -n1 || true)"
+ if [ -z "$fs_pid" ]; then
+ echo "FAIL: rns-filesync not running as rns-filesync"
+ ps aux || true
+ exit 1
+ fi
+ /test/assert-running.sh "$fs_pid" rns-filesync
+else
+ echo "==> active via systemd"
+ sleep 2
+ systemctl is-active rns-filesync.service | grep -qx active
+ systemctl stop rns-filesync.service
+fi
+
+echo "PASS: systemd"

diff --git a/vendor/rns_filesync/packaging/openrc/rns-filesync b/vendor/rns_filesync/packaging/openrc/rns-filesync
new file mode 100755
index 00000000..0079690d
--- /dev/null
+++ b/vendor/rns_filesync/packaging/openrc/rns-filesync
@@ -0,0 +1,24 @@
+#!/sbin/openrc-run
+
+description="RNS FileSync peer-to-peer directory sync"
+command="/usr/bin/rns-filesync"
+command_args="--config /var/lib/rns-filesync/config --rnsconfig /var/lib/rns-filesync/reticulum -d /var/lib/rns-filesync/sync --no-repl -q"
+command_user="rns-filesync:rns-filesync"
+command_background=yes
+pidfile="/run/rns-filesync.pid"
+directory="/var/lib/rns-filesync"
+
+export HOME=/var/lib/rns-filesync
+
+depend() {
+ need localmount
+ use net dns logger
+ after firewall
+}
+
+start_pre() {
+ checkpath -d -o rns-filesync:rns-filesync -m 0700 /var/lib/rns-filesync
+ checkpath -d -o rns-filesync:rns-filesync -m 0700 /var/lib/rns-filesync/config
+ checkpath -d -o rns-filesync:rns-filesync -m 0700 /var/lib/rns-filesync/reticulum
+ checkpath -d -o rns-filesync:rns-filesync -m 0700 /var/lib/rns-filesync/sync
+}

diff --git a/vendor/rns_filesync/packaging/rns-filesync.env.example b/vendor/rns_filesync/packaging/rns-filesync.env.example
new file mode 100644
index 00000000..c4d56579
--- /dev/null
+++ b/vendor/rns_filesync/packaging/rns-filesync.env.example
@@ -0,0 +1,5 @@
+# Optional environment overrides for system service installs.
+# Copy to /etc/default/rns-filesync or /etc/rns-filesync/env
+
+# Example:
+# RNS_LOGLEVEL=4

diff --git a/vendor/rns_filesync/packaging/runit/rns-filesync/run b/vendor/rns_filesync/packaging/runit/rns-filesync/run
new file mode 100755
index 00000000..e9ef5f71
--- /dev/null
+++ b/vendor/rns_filesync/packaging/runit/rns-filesync/run
@@ -0,0 +1,26 @@
+#!/bin/sh
+exec 2>&1
+install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync
+install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/config
+install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/reticulum
+install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/sync
+export HOME=/var/lib/rns-filesync
+cd /var/lib/rns-filesync || exit 1
+if command -v chpst >/dev/null 2>&1; then
+ exec chpst -u rns-filesync:rns-filesync /usr/bin/rns-filesync \
+ --config /var/lib/rns-filesync/config \
+ --rnsconfig /var/lib/rns-filesync/reticulum \
+ -d /var/lib/rns-filesync/sync \
+ --no-repl \
+ -q
+fi
+if command -v setuidgid >/dev/null 2>&1; then
+ exec setuidgid rns-filesync /usr/bin/rns-filesync \
+ --config /var/lib/rns-filesync/config \
+ --rnsconfig /var/lib/rns-filesync/reticulum \
+ -d /var/lib/rns-filesync/sync \
+ --no-repl \
+ -q
+fi
+exec su -s /bin/sh rns-filesync -c \
+ 'exec /usr/bin/rns-filesync --config /var/lib/rns-filesync/config --rnsconfig /var/lib/rns-filesync/reticulum -d /var/lib/rns-filesync/sync --no-repl -q'

diff --git a/vendor/rns_filesync/packaging/systemd/rns-filesync.service b/vendor/rns_filesync/packaging/systemd/rns-filesync.service
new file mode 100644
index 00000000..17068929
--- /dev/null
+++ b/vendor/rns_filesync/packaging/systemd/rns-filesync.service
@@ -0,0 +1,47 @@
+[Unit]
+Description=RNS FileSync peer-to-peer directory sync
+Documentation=man:rns-filesync(1)
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=rns-filesync
+Group=rns-filesync
+Environment=HOME=/var/lib/rns-filesync
+EnvironmentFile=-/etc/default/rns-filesync
+EnvironmentFile=-/etc/rns-filesync/env
+ExecStart=/usr/bin/rns-filesync \
+ --config /var/lib/rns-filesync/config \
+ --rnsconfig /var/lib/rns-filesync/reticulum \
+ -d /var/lib/rns-filesync/sync \
+ --no-repl \
+ -q
+Restart=on-failure
+RestartSec=5
+TimeoutStopSec=30
+KillMode=mixed
+
+NoNewPrivileges=true
+ProtectSystem=strict
+ProtectHome=true
+PrivateTmp=true
+PrivateDevices=true
+ProtectKernelTunables=true
+ProtectKernelModules=true
+ProtectControlGroups=true
+ProtectHostname=true
+ProtectClock=true
+RestrictSUIDSGID=true
+LockPersonality=true
+RestrictRealtime=true
+RestrictNamespaces=true
+SystemCallArchitectures=native
+CapabilityBoundingSet=
+AmbientCapabilities=
+RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
+ReadWritePaths=/var/lib/rns-filesync
+UMask=0077
+
+[Install]
+WantedBy=multi-user.target

diff --git a/vendor/rns_filesync/packaging/systemd/rns-filesync.user.service b/vendor/rns_filesync/packaging/systemd/rns-filesync.user.service
new file mode 100644
index 00000000..ea567872
--- /dev/null
+++ b/vendor/rns_filesync/packaging/systemd/rns-filesync.user.service
@@ -0,0 +1,23 @@
+[Unit]
+Description=RNS FileSync (user session)
+Documentation=man:rns-filesync(1)
+After=default.target
+
+[Service]
+Type=simple
+ExecStart=%h/.local/bin/rns-filesync \
+ --config %h/.rns_filesync \
+ --rnsconfig %h/.reticulum \
+ -d %h/Filesync \
+ --no-repl \
+ -q
+Restart=on-failure
+RestartSec=5
+TimeoutStopSec=30
+
+NoNewPrivileges=true
+PrivateTmp=true
+UMask=0077
+
+[Install]
+WantedBy=default.target

diff --git a/vendor/rns_filesync/packaging/sysusers.d/rns-filesync.conf b/vendor/rns_filesync/packaging/sysusers.d/rns-filesync.conf
new file mode 100644
index 00000000..891d91cb
--- /dev/null
+++ b/vendor/rns_filesync/packaging/sysusers.d/rns-filesync.conf
@@ -0,0 +1 @@
+u rns-filesync - "RNS FileSync" /var/lib/rns-filesync -

diff --git a/vendor/rns_filesync/packaging/tmpfiles.d/rns-filesync.conf b/vendor/rns_filesync/packaging/tmpfiles.d/rns-filesync.conf
new file mode 100644
index 00000000..1c5a7b33
--- /dev/null
+++ b/vendor/rns_filesync/packaging/tmpfiles.d/rns-filesync.conf
@@ -0,0 +1,4 @@
+d /var/lib/rns-filesync 0700 rns-filesync rns-filesync -
+d /var/lib/rns-filesync/config 0700 rns-filesync rns-filesync -
+d /var/lib/rns-filesync/reticulum 0700 rns-filesync rns-filesync -
+d /var/lib/rns-filesync/sync 0700 rns-filesync rns-filesync -

diff --git a/vendor/rns_filesync/permissions.example b/vendor/rns_filesync/permissions.example
new file mode 100644
index 00000000..02e24f16
--- /dev/null
+++ b/vendor/rns_filesync/permissions.example
@@ -0,0 +1,35 @@
+# RNS FileSync access file (.allowed)
+#
+# Same rule format as rngit: permission:target (one rule per line).
+# You can also put comma-separated rules on one line.
+#
+# Place this file as:
+# <sync_dir>.allowed
+# <parent>/<sync_dir_name>.allowed
+# <sync_dir>/.allowed
+#
+# Permissions:
+# r = read
+# w = write
+# d = delete
+# rw = read/write
+# rwd = read/write/delete
+# adm = admin (all)
+#
+# Targets: identity hash, alias from config, all, or none.
+#
+# Default is deny until rules are added.
+
+# Public read-only
+# r:all
+
+# Full access for one peer
+# rwd:b559704616f042e0b7b6ba46ada4c670
+
+# Read/write for one peer, read for everyone
+r:all
+w:b559704616f042e0b7b6ba46ada4c670
+d:b559704616f042e0b7b6ba46ada4c670
+
+# Or combined:
+# r:all, rw:20d1f3debb50dde3df2cff74413b0f0d

diff --git a/vendor/rns_filesync/pyproject.toml b/vendor/rns_filesync/pyproject.toml
new file mode 100644
index 00000000..57be99c2
--- /dev/null
+++ b/vendor/rns_filesync/pyproject.toml
@@ -0,0 +1,47 @@
+[build-system]
+requires = ["setuptools>=68", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "rns-filesync"
+version = "1.0.0"
+description = "Peer-to-peer file synchronization over the Reticulum Network Stack"
+readme = "README.md"
+license = { text = "BSD-2-Clause" }
+requires-python = ">=3.10"
+dependencies = [
+ "rns>=1.3.9",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=8.0",
+ "pytest-timeout>=2.3",
+ "hypothesis>=6.100",
+ "coverage>=7.0",
+]
+
+[project.scripts]
+rns-filesync = "rns_filesync.cli:main"
+
+[tool.setuptools.packages.find]
+include = ["rns_filesync*"]
+
+[tool.pytest.ini_options]
+markers = [
+ "unit: fast isolated unit tests",
+ "acceptance: behavior and policy acceptance tests",
+ "smoke: basic import and CLI smoke tests",
+ "e2e: end-to-end sync over isolated Reticulum",
+ "fuzz: property and fuzz tests",
+ "live: tests against a real Reticulum config",
+ "exploratory: longer stress and edge-case exploration",
+]
+testpaths = ["tests"]
+addopts = "-q --strict-markers"
+timeout = 120
+filterwarnings = ["ignore::DeprecationWarning"]
+
+[tool.coverage.run]
+source = ["rns_filesync"]
+omit = ["rns_filesync/cli.py"]

diff --git a/vendor/rns_filesync/requirements.txt b/vendor/rns_filesync/requirements.txt
new file mode 100644
index 00000000..717fcd58
--- /dev/null
+++ b/vendor/rns_filesync/requirements.txt
@@ -0,0 +1 @@
+rns>=1.3.9

diff --git a/vendor/rns_filesync/rns_filesync.py b/vendor/rns_filesync/rns_filesync.py
new file mode 100755
index 00000000..5fcf8f5f
--- /dev/null
+++ b/vendor/rns_filesync/rns_filesync.py
@@ -0,0 +1,7 @@
+#!/usr/bin/env python3
+"""Compatibility shim for python rns_filesync.py."""
+
+from rns_filesync.cli import main
+
+if __name__ == "__main__":
+ raise SystemExit(main())

diff --git a/vendor/rns_filesync/rns_filesync/__init__.py b/vendor/rns_filesync/rns_filesync/__init__.py
new file mode 100644
index 00000000..79af2e64
--- /dev/null
+++ b/vendor/rns_filesync/rns_filesync/__init__.py
@@ -0,0 +1,19 @@
+"""RNS FileSync: peer-to-peer directory sync over Reticulum."""
+
+from rns_filesync._meta import (
+ BUILD_DATE,
+ GIT_COMMIT,
+ __version__,
+ version_info,
+ version_string,
+)
+from rns_filesync.service import FileSyncService
+
+__all__ = [
+ "FileSyncService",
+ "__version__",
+ "BUILD_DATE",
+ "GIT_COMMIT",
+ "version_info",
+ "version_string",
+]

diff --git a/vendor/rns_filesync/rns_filesync/__main__.py b/vendor/rns_filesync/rns_filesync/__main__.py
new file mode 100644
index 00000000..e973e8b7
--- /dev/null
+++ b/vendor/rns_filesync/rns_filesync/__main__.py
@@ -0,0 +1,6 @@
+"""Module entrypoint for python -m rns_filesync."""
+
+from rns_filesync.cli import main
+
+if __name__ == "__main__":
+ raise SystemExit(main())

diff --git a/vendor/rns_filesync/rns_filesync/_meta.py b/vendor/rns_filesync/rns_filesync/_meta.py
new file mode 100644
index 00000000..1e0ae0ff
--- /dev/null
+++ b/vendor/rns_filesync/rns_filesync/_meta.py
@@ -0,0 +1,35 @@
+"""Package version and build metadata.
+
+Values are overwritten by make meta / make build when building from git.
+Defaults keep editable installs and tests working without a Makefile bake step.
+"""
+
+from __future__ import annotations
+
+__version__ = '1.0.0'
+
+BUILD_DATE = '2026-07-19T13:14:52Z'
+
+GIT_COMMIT = '12161f3'
+
+GIT_DIRTY = '1'
+
+
+def version_string() -> str:
+ """Single-line version for CLI -v / --version."""
+ parts = [f"rns-filesync {__version__}"]
+ if BUILD_DATE and BUILD_DATE != "unknown":
+ parts.append(f"built {BUILD_DATE}")
+ if GIT_COMMIT and GIT_COMMIT != "unknown":
+ dirty = "+" if GIT_DIRTY in {"1", "true", "yes", "dirty"} else ""
+ parts.append(f"commit {GIT_COMMIT}{dirty}")
+ return " | ".join(parts)
+
+
+def version_info() -> dict[str, str]:
+ return {
+ "version": __version__,
+ "build_date": BUILD_DATE,
+ "git_commit": GIT_COMMIT,
+ "git_dirty": GIT_DIRTY,
+ }

diff --git a/vendor/rns_filesync/rns_filesync/cli.py b/vendor/rns_filesync/rns_filesync/cli.py
new file mode 100644
index 00000000..e70a04d2
--- /dev/null
+++ b/vendor/rns_filesync/rns_filesync/cli.py
@@ -0,0 +1,339 @@
+"""Command-line interface for RNS FileSync."""
+
+from __future__ import annotations
+
+import argparse
+import os
+import sys
+import time
+
+import RNS
+
+from rns_filesync._meta import version_string
+from rns_filesync.config import (
+ allowed_sidecar_paths,
+ config_get,
+ load_config,
+ parse_csv_hashes,
+)
+from rns_filesync.constants import ANNOUNCE_INTERVAL_DEFAULT, APP_NAME
+from rns_filesync.permissions import PermissionStore
+from rns_filesync.service import FileSyncService
+
+
+def load_or_create_identity(identity_name: str, identity_dir: str):
+ os.makedirs(identity_dir, exist_ok=True)
+ identity_path = os.path.join(identity_dir, identity_name)
+ if os.path.isfile(identity_path):
+ identity = RNS.Identity.from_file(identity_path)
+ RNS.log(f"Loaded identity {identity_name}", RNS.LOG_INFO)
+ else:
+ identity = RNS.Identity()
+ identity.to_file(identity_path)
+ RNS.log(f"Created identity {identity_name}", RNS.LOG_INFO)
+ return identity
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="RNS FileSync - peer-to-peer file sync over Reticulum",
+ )
+ parser.add_argument(
+ "-v",
+ "--version",
+ action="version",
+ version=version_string(),
+ help="print version, build date, and commit then exit",
+ )
+ parser.add_argument(
+ "--config",
+ default=None,
+ help="FileSync config directory (default: ~/.rns_filesync)",
+ )
+ parser.add_argument(
+ "--rnsconfig",
+ default=None,
+ help="Reticulum config directory (default: ~/.reticulum)",
+ )
+ parser.add_argument(
+ "-d",
+ "--directory",
+ default=None,
+ help="directory to synchronize (overrides config)",
+ )
+ parser.add_argument(
+ "-i",
+ "--identity",
+ default=None,
+ help="identity name (stored under FileSync config dir)",
+ )
+ parser.add_argument(
+ "-p",
+ "--peer",
+ action="append",
+ dest="peers",
+ help="peer identity hash (destination hash accepted as fallback)",
+ )
+ parser.add_argument(
+ "-n",
+ "--no-monitor",
+ action="store_true",
+ help="disable file monitoring",
+ )
+ parser.add_argument(
+ "-a",
+ "--announce-interval",
+ type=int,
+ default=None,
+ help="announce interval in seconds",
+ )
+ parser.add_argument(
+ "--allowed",
+ default=None,
+ help="path to .allowed ACL file (rngit-style permission:target lines)",
+ )
+ parser.add_argument(
+ "--permissions-file",
+ default=None,
+ help="alias for --allowed (legacy name)",
+ )
+ parser.add_argument(
+ "--allow",
+ action="append",
+ dest="allowed_peers",
+ help="ACL rule (r:hash) or bare identity hash used with --perms",
+ )
+ parser.add_argument(
+ "--perms",
+ default="rwd",
+ help="legacy shorthand for bare --allow hashes (r, w, d, rw, rwd)",
+ )
+ parser.add_argument(
+ "--verbose",
+ action="count",
+ default=0,
+ help="increase logging (use --verbose, repeat for more)",
+ )
+ parser.add_argument("-q", "--quiet", action="store_true", help="reduce logging")
+ parser.add_argument(
+ "--no-repl",
+ action="store_true",
+ help="run without interactive command prompt",
+ )
+ return parser
+
+
+def _set_loglevel(verbose: int, quiet: bool, config_level: int | None) -> None:
+ if quiet:
+ RNS.loglevel = RNS.LOG_ERROR
+ return
+ if verbose == 1:
+ RNS.loglevel = RNS.LOG_VERBOSE
+ elif verbose == 2:
+ RNS.loglevel = RNS.LOG_DEBUG
+ elif verbose >= 3:
+ RNS.loglevel = RNS.LOG_EXTREME
+ elif config_level is not None:
+ RNS.loglevel = int(config_level)
+ else:
+ RNS.loglevel = RNS.LOG_INFO
+
+
+def _print_help() -> None:
+ print(
+ "commands: status peers connect <hash> disconnect <id> browse <id> "
+ "download <id> <path> announce files quit",
+ )
+
+
+def run_repl(service: FileSyncService) -> None:
+ _print_help()
+ while True:
+ try:
+ line = input("filesync> ").strip()
+ except (EOFError, KeyboardInterrupt):
+ print()
+ break
+ if not line:
+ continue
+ parts = line.split()
+ cmd = parts[0].lower()
+ if cmd in {"quit", "exit", "q"}:
+ break
+ if cmd in {"help", "?"}:
+ _print_help()
+ elif cmd == "status":
+ print(service.get_status())
+ elif cmd == "peers":
+ for peer in service.list_peers():
+ print(peer)
+ elif cmd == "files":
+ for item in service.list_files():
+ print(f"{item['path']}\t{item['size']}\t{item['hash']}")
+ elif cmd == "announce":
+ service.announce_now()
+ print("announced")
+ elif cmd == "connect" and len(parts) >= 2:
+ print(service.connect_peer(parts[1]))
+ elif cmd == "disconnect" and len(parts) >= 2:
+ service.disconnect_peer(parts[1])
+ print("disconnected")
+ elif cmd == "browse" and len(parts) >= 2:
+ for item in service.browse_peer(parts[1]):
+ print(item)
+ elif cmd == "download" and len(parts) >= 3:
+ print(service.download_file(parts[1], parts[2]))
+ else:
+ print("unknown command")
+ _print_help()
+
+
+def build_permissions(
+ *,
+ config,
+ sync_directory: str,
+ allowed_path: str | None,
+ allow_args: list[str] | None,
+ perms_shorthand: str,
+) -> PermissionStore:
+ store = PermissionStore()
+
+ aliases = config.get("aliases") or {}
+ if isinstance(aliases, dict):
+ store.set_aliases(
+ {str(k): str(v) for k, v in aliases.items() if not str(k).startswith("#")},
+ )
+
+ blocked = config_get(config, "filesync", "blocked_identities", None)
+ store.set_blocked(parse_csv_hashes(blocked))
+
+ access = config.get("access") or {}
+ if isinstance(access, dict):
+ for key, value in access.items():
+ if str(key).startswith("#"):
+ continue
+ store.load_access_value(value)
+
+ for path in allowed_sidecar_paths(sync_directory):
+ if os.path.isfile(path):
+ loaded = store.load_file(path)
+ if loaded:
+ RNS.log(f"Loaded {loaded} ACL rules from {path}", RNS.LOG_INFO)
+
+ if allowed_path:
+ store.load_file(allowed_path)
+
+ if allow_args:
+ for item in allow_args:
+ item = item.strip()
+ if ":" in item:
+ store.add_rule(item)
+ else:
+ # Bare hash: apply --perms shorthand as rngit-style rule(s).
+ shorthand = perms_shorthand.strip().lower()
+ if shorthand in {"r", "w", "d", "rw", "rwd", "adm", "admin"}:
+ store.add_rule(f"{shorthand}:{item}")
+ else:
+ # Comma list of long names
+ store.grant(item, [p.strip() for p in shorthand.split(",")])
+
+ return store
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+
+ config_dir, config = load_config(args.config)
+ config_level = config_get(config, "logging", "loglevel", None)
+ try:
+ config_level = int(config_level) if config_level is not None else None
+ except (TypeError, ValueError):
+ config_level = None
+ _set_loglevel(args.verbose, args.quiet, config_level)
+
+ directory = args.directory or config_get(config, "filesync", "directory", None)
+ if not directory:
+ parser.error(
+ "sync directory required via -d/--directory or config [filesync] directory",
+ )
+ directory = os.path.realpath(os.path.expanduser(str(directory)))
+
+ identity_name = args.identity or config_get(
+ config,
+ "filesync",
+ "identity",
+ APP_NAME,
+ )
+ announce_interval = args.announce_interval
+ if announce_interval is None:
+ raw = config_get(
+ config,
+ "filesync",
+ "announce_interval",
+ ANNOUNCE_INTERVAL_DEFAULT,
+ )
+ try:
+ announce_interval = int(raw)
+ except (TypeError, ValueError):
+ announce_interval = ANNOUNCE_INTERVAL_DEFAULT
+
+ peers = list(args.peers or [])
+ peers.extend(parse_csv_hashes(config_get(config, "filesync", "peers", None)))
+
+ allowed_path = args.allowed or args.permissions_file
+ permissions = build_permissions(
+ config=config,
+ sync_directory=directory,
+ allowed_path=allowed_path,
+ allow_args=args.allowed_peers,
+ perms_shorthand=args.perms,
+ )
+
+ rnsconfig = args.rnsconfig
+ reticulum = RNS.Reticulum(rnsconfig)
+ identity = load_or_create_identity(
+ str(identity_name),
+ os.path.join(config_dir, "identities"),
+ )
+
+ service = FileSyncService(
+ identity=identity,
+ sync_directory=directory,
+ reticulum=reticulum,
+ configpath=rnsconfig,
+ permissions=permissions,
+ own_reticulum=True,
+ )
+ dest = service.start(
+ monitor=not args.no_monitor,
+ announce_interval=announce_interval,
+ )
+ print(f"config={config_dir}")
+ print(f"identity={service.get_status()['identity_hash']}")
+ print(f"destination={dest}")
+ print(f"directory={directory}")
+ if permissions.enabled:
+ print("acl=enforced (deny by default)")
+ else:
+ print("acl=open (no rules configured)")
+
+ if peers:
+ time.sleep(1.0)
+ for peer in peers:
+ result = service.connect_peer(peer)
+ print(f"connect {peer}: {result}")
+
+ try:
+ if args.no_repl:
+ while True:
+ time.sleep(1.0)
+ else:
+ run_repl(service)
+ finally:
+ service.stop()
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())

diff --git a/vendor/rns_filesync/rns_filesync/config.py b/vendor/rns_filesync/rns_filesync/config.py
new file mode 100644
index 00000000..9509cbdd
--- /dev/null
+++ b/vendor/rns_filesync/rns_filesync/config.py
@@ -0,0 +1,116 @@
+"""Config loading for RNS FileSync (rngit-style ConfigObj)."""
+
+from __future__ import annotations
+
+import os
+from typing import Any
+
+from RNS.vendor.configobj import ConfigObj
+
+DEFAULT_CONFIG_DIR = os.path.expanduser("~/.rns_filesync")
+
+DEFAULT_CONFIG = """# RNS FileSync config
+# Similar layout to rngit (~/.rngit/config).
+
+[filesync]
+
+# Automatic announce interval in seconds.
+announce_interval = 300
+
+# Optional default sync directory (overridden by -d).
+# directory = ~/shared
+
+# Identity name stored under this config dir.
+identity = rns_filesync
+
+# Comma-separated peer identity hashes to connect on start.
+# peers = 9710b86ba12c42d1d8f30f74fe509286
+
+# Deny these identity hashes even if access rules would allow them.
+# blocked_identities = d31aeea49873006f13b3415520666a4e
+
+[aliases]
+# alice = d09285e660cfe27cee6d9a0beb58b7e0
+
+[access]
+# Access rules for the sync directory (comma-separated).
+# Format matches rngit: permission:target
+#
+# Permissions:
+# r = read
+# w = write
+# d = delete
+# rw = read/write
+# rwd = read/write/delete
+# adm = admin (all permissions)
+#
+# Targets: identity hash, alias name, all, or none.
+#
+# By default there are no permissions: peers cannot sync
+# until you add rules here and/or a sidecar .allowed file.
+#
+# sync = r:all, w:9710b86ba12c42d1d8f30f74fe509286, d:9710b86ba12c42d1d8f30f74fe509286
+
+[logging]
+loglevel = 4
+"""
+
+
+def resolve_config_dir(config_dir: str | None = None) -> str:
+ if config_dir:
+ return os.path.realpath(os.path.expanduser(config_dir))
+ if os.path.isfile("/etc/rns_filesync/config"):
+ return "/etc/rns_filesync"
+ return DEFAULT_CONFIG_DIR
+
+
+def ensure_config(config_dir: str | None = None) -> str:
+ """Ensure config directory and default config file exist. Return config dir."""
+ path = resolve_config_dir(config_dir)
+ os.makedirs(path, exist_ok=True)
+ os.makedirs(os.path.join(path, "identities"), exist_ok=True)
+ config_path = os.path.join(path, "config")
+ if not os.path.isfile(config_path):
+ with open(config_path, "w", encoding="utf-8") as handle:
+ handle.write(DEFAULT_CONFIG)
+ return path
+
+
+def load_config(config_dir: str | None = None) -> tuple[str, ConfigObj]:
+ """Load ConfigObj from config dir, creating defaults if needed."""
+ path = ensure_config(config_dir)
+ config_path = os.path.join(path, "config")
+ config = ConfigObj(config_path)
+ return path, config
+
+
+def config_get(config: ConfigObj, section: str, key: str, default: Any = None) -> Any:
+ try:
+ section_obj = config.get(section) or {}
+ if key in section_obj and section_obj[key] not in (None, ""):
+ return section_obj[key]
+ except Exception:
+ pass
+ return default
+
+
+def parse_csv_hashes(value: str | list | None) -> list[str]:
+ if value is None:
+ return []
+ if isinstance(value, list):
+ parts = value
+ else:
+ parts = str(value).split(",")
+ return [p.strip().lower().replace(":", "") for p in parts if p and str(p).strip()]
+
+
+def allowed_sidecar_paths(sync_directory: str) -> list[str]:
+ """Candidate .allowed paths for a sync directory (rngit-style sidecars)."""
+ sync_directory = os.path.realpath(os.path.expanduser(sync_directory))
+ parent = os.path.dirname(sync_directory.rstrip(os.sep))
+ base = os.path.basename(sync_directory.rstrip(os.sep))
+ return [
+ os.path.join(sync_directory, ".allowed"),
+ os.path.join(parent, f"{base}.allowed"),
+ sync_directory.rstrip(os.sep) + ".allowed",
+ ]

diff --git a/vendor/rns_filesync/rns_filesync/constants.py b/vendor/rns_filesync/rns_filesync/constants.py
new file mode 100644
index 00000000..a002b3b6
--- /dev/null
+++ b/vendor/rns_filesync/rns_filesync/constants.py
@@ -0,0 +1,15 @@
+"""Shared constants for RNS FileSync."""
+
+APP_NAME = "rns_filesync"
+ASPECT = "filesync"
+BLOCK_SIZE = 4096
+HASH_CHUNK_SIZE = 65536
+SCAN_INTERVAL = 5.0
+ANNOUNCE_INTERVAL_DEFAULT = 300
+PATH_TIMEOUT_DEFAULT = 30.0
+LINK_TIMEOUT_DEFAULT = 15.0
+RECONNECT_BASE_INTERVAL = 5.0
+RECONNECT_MAX_INTERVAL = 60.0
+HASH_DB_NAME = ".rns-filesync.db"
+VALID_PERMISSIONS = frozenset({"read", "write", "delete"})
+EMPTY_FILE_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"

diff --git a/vendor/rns_filesync/rns_filesync/inventory.py b/vendor/rns_filesync/rns_filesync/inventory.py
new file mode 100644
index 00000000..1bb3b9a2
--- /dev/null
+++ b/vendor/rns_filesync/rns_filesync/inventory.py
@@ -0,0 +1,197 @@
+"""Local file inventory, hashing, and persistence."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import threading
+from typing import Any
+
+from rns_filesync.constants import BLOCK_SIZE, HASH_CHUNK_SIZE, HASH_DB_NAME
+from rns_filesync.paths import PathJailError, normalize_relpath, relative_to_root
+
+
+def hash_file(filepath: str) -> str | None:
+ """Return SHA-256 hex digest of a file, or None on error."""
+ hasher = hashlib.sha256()
+ try:
+ with open(filepath, "rb") as handle:
+ while chunk := handle.read(HASH_CHUNK_SIZE):
+ hasher.update(chunk)
+ return hasher.hexdigest()
+ except OSError:
+ return None
+
+
+def hash_blocks(filepath: str, block_size: int = BLOCK_SIZE) -> list[dict[str, Any]]:
+ """Return per-block SHA-256 hashes for delta sync."""
+ blocks: list[dict[str, Any]] = []
+ try:
+ with open(filepath, "rb") as handle:
+ block_num = 0
+ while block := handle.read(block_size):
+ blocks.append(
+ {
+ "num": block_num,
+ "hash": hashlib.sha256(block).hexdigest(),
+ "size": len(block),
+ },
+ )
+ block_num += 1
+ except OSError:
+ return []
+ return blocks
+
+
+def differing_block_nums(
+ local_blocks: list[dict[str, Any]],
+ peer_block_hashes: list[str],
+) -> list[int]:
+ """Return block numbers present locally that the peer does not have."""
+ peer_set = set(peer_block_hashes)
+ return [b["num"] for b in local_blocks if b["hash"] not in peer_set]
+
+
+def decide_sync_action(
+ local_info: dict[str, Any] | None,
+ peer_info: dict[str, Any] | None,
+) -> str:
+ """Decide sync action for one path.
+
+ Returns one of: skip, request_full, request_delta, ignore.
+ """
+ if peer_info is None:
+ return "ignore"
+ if local_info is None:
+ return "request_full"
+ if local_info.get("hash") == peer_info.get("hash"):
+ return "skip"
+ if local_info.get("size", 0) > 0:
+ return "request_delta"
+ return "request_full"
+
+
+class Inventory:
+ """Tracks file hashes under a sync directory."""
+
+ def __init__(self, sync_directory: str):
+ self.sync_directory = os.path.realpath(sync_directory)
+ self._lock = threading.RLock()
+ self._files: dict[str, dict[str, Any]] = {}
+
+ def load(self) -> int:
+ db_path = os.path.join(self.sync_directory, HASH_DB_NAME)
+ if not os.path.isfile(db_path):
+ with self._lock:
+ self._files = {}
+ return 0
+ try:
+ with open(db_path, encoding="utf-8") as handle:
+ data = json.load(handle)
+ if not isinstance(data, dict):
+ data = {}
+ except (OSError, json.JSONDecodeError):
+ data = {}
+ with self._lock:
+ self._files = data
+ return len(data)
+
+ def save(self) -> None:
+ db_path = os.path.join(self.sync_directory, HASH_DB_NAME)
+ tmp_path = db_path + ".tmp"
+ with self._lock:
+ payload = dict(self._files)
+ with open(tmp_path, "w", encoding="utf-8") as handle:
+ json.dump(payload, handle, indent=2, sort_keys=True)
+ os.replace(tmp_path, db_path)
+
+ def get(self, relpath: str) -> dict[str, Any] | None:
+ with self._lock:
+ info = self._files.get(relpath)
+ return dict(info) if info else None
+
+ def snapshot(self) -> dict[str, dict[str, Any]]:
+ with self._lock:
+ return {k: dict(v) for k, v in self._files.items()}
+
+ def set_file(self, relpath: str, info: dict[str, Any]) -> None:
+ with self._lock:
+ self._files[relpath] = dict(info)
+
+ def remove_file(self, relpath: str) -> None:
+ with self._lock:
+ self._files.pop(relpath, None)
+
+ def _hash_if_needed(
+ self,
+ abspath: str,
+ relpath: str,
+ size: int,
+ mtime: float,
+ ) -> dict[str, Any] | None:
+ with self._lock:
+ cached = self._files.get(relpath)
+ if (
+ cached
+ and cached.get("size") == size
+ and abs(float(cached.get("mtime", 0)) - mtime) < 1e-6
+ and cached.get("hash")
+ ):
+ return {
+ "hash": cached["hash"],
+ "size": size,
+ "mtime": mtime,
+ }
+ digest = hash_file(abspath)
+ if not digest:
+ return None
+ return {"hash": digest, "size": size, "mtime": mtime}
+
+ def scan(self) -> dict[str, dict[str, Any]]:
+ """Scan sync directory and return current file map."""
+ found: dict[str, dict[str, Any]] = {}
+ for root, _dirs, files in os.walk(self.sync_directory):
+ for filename in files:
+ if filename == HASH_DB_NAME or filename.startswith(".rns-filesync"):
+ continue
+ abspath = os.path.join(root, filename)
+ try:
+ relpath = relative_to_root(self.sync_directory, abspath)
+ except PathJailError:
+ continue
+ try:
+ stat = os.stat(abspath)
+ except OSError:
+ continue
+ info = self._hash_if_needed(
+ abspath,
+ relpath,
+ stat.st_size,
+ stat.st_mtime,
+ )
+ if info:
+ found[relpath] = info
+ with self._lock:
+ self._files = found
+ return self.snapshot()
+
+ def update_from_path(self, relpath: str) -> dict[str, Any] | None:
+ try:
+ safe = normalize_relpath(relpath)
+ abspath = os.path.join(self.sync_directory, safe)
+ if not os.path.isfile(abspath):
+ self.remove_file(safe)
+ return None
+ stat = os.stat(abspath)
+ info = {
+ "hash": hash_file(abspath),
+ "size": stat.st_size,
+ "mtime": stat.st_mtime,
+ }
+ if not info["hash"]:
+ return None
+ self.set_file(safe, info)
+ return info
+ except (OSError, PathJailError):
+ return None

diff --git a/vendor/rns_filesync/rns_filesync/paths.py b/vendor/rns_filesync/rns_filesync/paths.py
new file mode 100644
index 00000000..c9052a6c
--- /dev/null
+++ b/vendor/rns_filesync/rns_filesync/paths.py
@@ -0,0 +1,70 @@
+"""Path jail helpers for sync directory confinement."""
+
+from __future__ import annotations
+
+import os
+
+
+class PathJailError(ValueError):
+ """Raised when a path escapes the sync root."""
+
+
+def normalize_relpath(relpath: str) -> str:
+ """Normalize a relative path for protocol use.
+
+ Raises:
+ PathJailError: If the path is empty, absolute, or contains null bytes.
+
+ """
+ if not isinstance(relpath, str) or not relpath or "\x00" in relpath:
+ raise PathJailError("invalid relative path")
+ # Reject Windows drive / UNC style paths even on POSIX.
+ if relpath.startswith(("/", "\\")) or os.path.isabs(relpath):
+ raise PathJailError("absolute paths are not allowed")
+ if len(relpath) >= 2 and relpath[1] == ":":
+ raise PathJailError("absolute paths are not allowed")
+ if relpath.startswith("\\\\") or relpath.startswith("//"):
+ raise PathJailError("absolute paths are not allowed")
+
+ cleaned = os.path.normpath(relpath.replace("\\", "/"))
+ if cleaned in (".", ""):
+ raise PathJailError("empty relative path")
+ parts = cleaned.split("/")
+ if any(part in ("", "..") for part in parts):
+ raise PathJailError("path escape attempt")
+ if cleaned.startswith("../") or cleaned == "..":
+ raise PathJailError("path escape attempt")
+ # Disallow hidden protocol/control filenames under sync root.
+ if any(
+ part == ".rns-filesync.db" or part.startswith(".rns-xfer-") for part in parts
+ ):
+ raise PathJailError("reserved path")
+ return cleaned
+
+
+def resolve_under_root(root: str, relpath: str) -> str:
+ """Resolve relpath under root and ensure it stays jailed.
+
+ Returns:
+ Absolute real path under root.
+
+ Raises:
+ PathJailError: If resolution escapes root.
+
+ """
+ root_real = os.path.realpath(root)
+ safe_rel = normalize_relpath(relpath)
+ candidate = os.path.realpath(os.path.join(root_real, safe_rel))
+ if candidate != root_real and not candidate.startswith(root_real + os.sep):
+ raise PathJailError("path escapes sync root")
+ return candidate
+
+
+def relative_to_root(root: str, abspath: str) -> str:
+ """Return a jailed relative path from an absolute path under root."""
+ root_real = os.path.realpath(root)
+ path_real = os.path.realpath(abspath)
+ if path_real != root_real and not path_real.startswith(root_real + os.sep):
+ raise PathJailError("path escapes sync root")
+ rel = os.path.relpath(path_real, root_real)
+ return normalize_relpath(rel)

diff --git a/vendor/rns_filesync/rns_filesync/peers.py b/vendor/rns_filesync/rns_filesync/peers.py
new file mode 100644
index 00000000..7b76cfad
--- /dev/null
+++ b/vendor/rns_filesync/rns_filesync/peers.py
@@ -0,0 +1,149 @@
+"""Peer link lifecycle helpers."""
+
+from __future__ import annotations
+
+import time
+from collections.abc import Callable
+
+import RNS
+
+from rns_filesync.constants import (
+ APP_NAME,
+ ASPECT,
+ LINK_TIMEOUT_DEFAULT,
+ PATH_TIMEOUT_DEFAULT,
+)
+
+
+def parse_hash(value: str | bytes) -> bytes:
+ if isinstance(value, bytes):
+ if len(value) == RNS.Reticulum.TRUNCATED_HASHLENGTH // 8:
+ return value
+ raise ValueError("invalid identity/destination hash length")
+ cleaned = value.lower().replace(":", "").strip()
+ raw = bytes.fromhex(cleaned)
+ expected = RNS.Reticulum.TRUNCATED_HASHLENGTH // 8
+ if len(raw) != expected:
+ raise ValueError(f"hash must be {expected} bytes")
+ return raw
+
+
+def hex_hash(value: bytes) -> str:
+ return RNS.hexrep(value, delimit=False)
+
+
+def resolve_peer_identity(peer_hash: bytes):
+ """Resolve an identity from identity hash or destination hash."""
+ identity = RNS.Identity.recall(peer_hash, from_identity_hash=True)
+ if identity is not None:
+ return identity, "identity"
+
+ identity = RNS.Identity.recall(peer_hash, from_identity_hash=False)
+ if identity is not None:
+ return identity, "destination"
+
+ # Same-process local destinations may not yet be in known_destinations.
+ for destination in list(RNS.Transport.destinations):
+ try:
+ if destination.identity is None:
+ continue
+ if destination.identity.hash == peer_hash:
+ identity = RNS.Identity(create_keys=False)
+ identity.load_public_key(destination.identity.get_public_key())
+ return identity, "local"
+ if destination.hash == peer_hash:
+ identity = RNS.Identity(create_keys=False)
+ identity.load_public_key(destination.identity.get_public_key())
+ return identity, "local_destination"
+ except Exception:
+ continue
+
+ return None, None
+
+
+def is_local_destination(destination_hash: bytes) -> bool:
+ """Return True when the destination is registered on this Reticulum instance."""
+ for destination in list(RNS.Transport.destinations):
+ try:
+ if destination.hash == destination_hash:
+ return True
+ except Exception:
+ continue
+ destinations_map = getattr(RNS.Transport, "destinations_map", None)
+ if destinations_map is not None and destination_hash in destinations_map:
+ return True
+ return False
+
+
+def wait_for_path(
+ destination_hash: bytes,
+ timeout: float = PATH_TIMEOUT_DEFAULT,
+) -> bool:
+ if RNS.Transport.has_path(destination_hash) or is_local_destination(
+ destination_hash,
+ ):
+ return True
+ RNS.Transport.request_path(destination_hash)
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ if RNS.Transport.has_path(destination_hash) or is_local_destination(
+ destination_hash,
+ ):
+ return True
+ time.sleep(0.2)
+ return RNS.Transport.has_path(destination_hash) or is_local_destination(
+ destination_hash,
+ )
+
+
+def create_outbound_destination(peer_identity):
+ return RNS.Destination(
+ peer_identity,
+ RNS.Destination.OUT,
+ RNS.Destination.SINGLE,
+ APP_NAME,
+ ASPECT,
+ )
+
+
+def establish_link(
+ destination,
+ *,
+ established_callback: Callable | None = None,
+ closed_callback: Callable | None = None,
+ timeout: float = LINK_TIMEOUT_DEFAULT,
+):
+ link = RNS.Link(
+ destination,
+ established_callback=established_callback,
+ closed_callback=closed_callback,
+ )
+ deadline = time.time() + timeout
+ while link.status not in (RNS.Link.ACTIVE, RNS.Link.CLOSED):
+ if time.time() > deadline:
+ try:
+ link.teardown()
+ except Exception:
+ pass
+ return None
+ time.sleep(0.05)
+ if link.status != RNS.Link.ACTIVE:
+ return None
+ return link
+
+
+def peer_id_from_link(link) -> str | None:
+ try:
+ remote = link.get_remote_identity()
+ if remote is not None:
+ return hex_hash(remote.hash)
+ except Exception:
+ pass
+ return None
+
+
+def destination_id_from_link(link) -> str | None:
+ try:
+ return hex_hash(link.destination.hash)
+ except Exception:
+ return None

diff --git a/vendor/rns_filesync/rns_filesync/permissions.py b/vendor/rns_filesync/rns_filesync/permissions.py
new file mode 100644
index 00000000..fa626b84
--- /dev/null
+++ b/vendor/rns_filesync/rns_filesync/permissions.py
@@ -0,0 +1,261 @@
+"""rngit-style access control for FileSync.
+
+Rules use permission:target (for example r:all, w:<hash>).
+When ACL enforcement is active, access is denied by default.
+"""
+
+from __future__ import annotations
+
+import os
+import threading
+from collections.abc import Iterable
+
+from rns_filesync.constants import VALID_PERMISSIONS
+
+TGT_ALL = "all"
+TGT_NONE = "none"
+
+PERM_MAP = {
+ "r": ("read",),
+ "read": ("read",),
+ "w": ("write",),
+ "write": ("write",),
+ "d": ("delete",),
+ "delete": ("delete",),
+ "rw": ("read", "write"),
+ "readwrite": ("read", "write"),
+ "rwd": ("read", "write", "delete"),
+ "adm": ("read", "write", "delete"),
+ "admin": ("read", "write", "delete"),
+}
+
+TGT_ALL_ALIASES = frozenset({"all", "a", "everyone", "*"})
+TGT_NONE_ALIASES = frozenset({"none", "n", "nobody"})
+
+
+def _normalize_hash(value: bytes | str) -> str:
+ if isinstance(value, bytes):
+ return value.hex()
+ return value.lower().replace(":", "").strip()
+
+
+class PermissionStore:
+ """Thread-safe ACL using rngit-style permission:target rules."""
+
+ def __init__(
+ self,
+ entries: dict[str, list[str]] | None = None,
+ *,
+ enforce: bool | None = None,
+ ):
+ self._lock = threading.RLock()
+ self._aliases: dict[str, str] = {}
+ self._blocked: set[str] = set()
+ # permission -> set of targets (hash hex, ALL, NONE)
+ self._rules: dict[str, set[str]] = {
+ "read": set(),
+ "write": set(),
+ "delete": set(),
+ }
+ self._admin: set[str] = set()
+ self._enforce = False if enforce is None else bool(enforce)
+ if entries:
+ for identity_hash, perms in entries.items():
+ self.grant(identity_hash, perms)
+ if enforce is None:
+ self._enforce = True
+
+ @property
+ def enabled(self) -> bool:
+ """True when ACL enforcement is active (deny by default)."""
+ with self._lock:
+ return self._enforce
+
+ def set_alias(self, name: str, identity_hash: str) -> None:
+ with self._lock:
+ self._aliases[name.strip().lower()] = _normalize_hash(identity_hash)
+
+ def set_aliases(self, aliases: dict[str, str]) -> None:
+ for name, identity_hash in aliases.items():
+ self.set_alias(str(name), str(identity_hash))
+
+ def block(self, identity_hash: str | bytes) -> None:
+ with self._lock:
+ self._blocked.add(_normalize_hash(identity_hash))
+
+ def set_blocked(self, hashes: Iterable[str | bytes]) -> None:
+ for value in hashes:
+ self.block(value)
+
+ def _resolve_target(self, target: str) -> str | None:
+ target = target.strip()
+ if not target:
+ return None
+ lower = target.lower()
+ if lower in self._aliases:
+ return self._aliases[lower]
+ if lower in TGT_ALL_ALIASES:
+ return TGT_ALL
+ if lower in TGT_NONE_ALIASES:
+ return TGT_NONE
+ cleaned = _normalize_hash(target)
+ if len(cleaned) == 32:
+ try:
+ bytes.fromhex(cleaned)
+ except ValueError:
+ return None
+ return cleaned
+ return None
+
+ def parse_rule(self, rule: str) -> tuple[tuple[str, ...], str] | None:
+ """Parse perm:target into permission names and resolved target."""
+ rule = rule.strip()
+ if not rule or rule.startswith("#"):
+ return None
+ if ":" not in rule:
+ return None
+ perm_s, target_s = rule.split(":", 1)
+ perms = PERM_MAP.get(perm_s.strip().lower())
+ if not perms:
+ return None
+ with self._lock:
+ target = self._resolve_target(target_s)
+ if target is None:
+ return None
+ return perms, target
+
+ def add_rule(self, rule: str) -> bool:
+ parsed = self.parse_rule(rule)
+ if not parsed:
+ return False
+ perms, target = parsed
+ perm_key = rule.split(":", 1)[0].strip().lower()
+ with self._lock:
+ self._enforce = True
+ if perm_key in ("adm", "admin"):
+ self._admin.add(target)
+ for perm in perms:
+ self._rules[perm].add(target)
+ return True
+
+ def add_rules(self, rules: Iterable[str]) -> int:
+ loaded = 0
+ for rule in rules:
+ if self.add_rule(rule):
+ loaded += 1
+ return loaded
+
+ def grant(self, identity_hash: str, perms: Iterable[str]) -> list[str]:
+ """Legacy helper: grant named permissions to one identity hash."""
+ valid = []
+ for perm in perms:
+ p = str(perm).strip().lower()
+ if p in VALID_PERMISSIONS and p not in valid:
+ valid.append(p)
+ if not valid:
+ return []
+ key = _normalize_hash(identity_hash)
+ if key in TGT_ALL_ALIASES or key == "*":
+ key = TGT_ALL
+ with self._lock:
+ self._enforce = True
+ for perm in valid:
+ self._rules[perm].add(key)
+ return valid
+
+ def set_permissions(self, identity_hash: str, perms: Iterable[str]) -> list[str]:
+ return self.grant(identity_hash, perms)
+
+ def load_allowed_text(self, text: str) -> int:
+ loaded = 0
+ for raw in text.splitlines():
+ line = raw.strip()
+ if not line or line.startswith("#"):
+ continue
+ if ":" in line:
+ for part in [p.strip() for p in line.split(",") if p.strip()]:
+ if self.add_rule(part):
+ loaded += 1
+ elif self._load_legacy_line(line):
+ loaded += 1
+ return loaded
+
+ def _load_legacy_line(self, line: str) -> bool:
+ parts = line.split()
+ if len(parts) < 2:
+ return False
+ identity_hash = parts[0]
+ perms = [p.strip() for p in parts[1].split(",")]
+ return bool(self.grant(identity_hash, perms))
+
+ def load_file(self, path: str) -> int:
+ if not os.path.isfile(path):
+ return 0
+ if os.access(path, os.X_OK):
+ # Executable .allowed: run and parse stdout (rngit behavior).
+ import subprocess
+
+ try:
+ result = subprocess.run(
+ [path],
+ capture_output=True,
+ text=True,
+ check=False,
+ timeout=30,
+ )
+ return self.load_allowed_text(result.stdout or "")
+ except Exception:
+ return 0
+ with open(path, encoding="utf-8") as handle:
+ return self.load_allowed_text(handle.read())
+
+ def load_access_value(self, value: str | list | None) -> int:
+ if value is None:
+ return 0
+ if isinstance(value, list):
+ text = ", ".join(str(v) for v in value)
+ else:
+ text = str(value)
+ return self.load_allowed_text(text)
+
+ def check(self, identity_hash: bytes | str, permission: str) -> bool:
+ permission = permission.lower()
+ if permission not in VALID_PERMISSIONS:
+ return False
+ key = _normalize_hash(identity_hash)
+ with self._lock:
+ if key in self._blocked:
+ return False
+ if not self._enforce:
+ return True
+ targets = self._rules.get(permission, set())
+ if TGT_NONE in targets:
+ return False
+ if TGT_ALL in targets or key in targets:
+ return True
+ if TGT_ALL in self._admin or key in self._admin:
+ return True
+ return False
+
+ def can_connect(self, identity_hash: bytes | str) -> bool:
+ key = _normalize_hash(identity_hash)
+ with self._lock:
+ if key in self._blocked:
+ return False
+ if not self._enforce:
+ return True
+ if TGT_ALL in self._admin or key in self._admin:
+ return True
+ for targets in self._rules.values():
+ if TGT_NONE in targets:
+ continue
+ if TGT_ALL in targets or key in targets:
+ return True
+ return False
+
+ def get(self, identity_hash: bytes | str) -> list[str]:
+ return [p for p in ("read", "write", "delete") if self.check(identity_hash, p)]
+
+ def as_dict(self) -> dict[str, list[str]]:
+ with self._lock:
+ return {perm: sorted(targets) for perm, targets in self._rules.items()}

diff --git a/vendor/rns_filesync/rns_filesync/protocol.py b/vendor/rns_filesync/rns_filesync/protocol.py
new file mode 100644
index 00000000..8cb28421
--- /dev/null
+++ b/vendor/rns_filesync/rns_filesync/protocol.py
@@ -0,0 +1,108 @@
+"""Control-plane message encode and decode."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from RNS.vendor import umsgpack
+
+MSG_FILE_LIST = "file_list"
+MSG_FILE_LIST_REQUEST = "file_list_request"
+MSG_FILE_REQUEST = "file_request"
+MSG_DELTA_REQUEST = "delta_request"
+MSG_EMPTY_FILE = "empty_file"
+MSG_FILE_UPDATE = "file_update"
+MSG_FILE_DELETION = "file_deletion"
+
+KNOWN_TYPES = frozenset(
+ {
+ MSG_FILE_LIST,
+ MSG_FILE_LIST_REQUEST,
+ MSG_FILE_REQUEST,
+ MSG_DELTA_REQUEST,
+ MSG_EMPTY_FILE,
+ MSG_FILE_UPDATE,
+ MSG_FILE_DELETION,
+ },
+)
+
+
+class ProtocolError(ValueError):
+ """Raised for invalid protocol messages."""
+
+
+def encode_message(payload: dict[str, Any]) -> bytes:
+ if not isinstance(payload, dict) or "type" not in payload:
+ raise ProtocolError("message must be a dict with type")
+ return umsgpack.packb(payload)
+
+
+def decode_message(raw: bytes) -> dict[str, Any]:
+ data = umsgpack.unpackb(raw)
+ if not isinstance(data, dict):
+ raise ProtocolError("message must unpack to dict")
+ msg_type = data.get("type")
+ if not isinstance(msg_type, str) or msg_type not in KNOWN_TYPES:
+ raise ProtocolError(f"unknown message type: {msg_type!r}")
+ return data
+
+
+def make_file_list(files: dict[str, dict[str, Any]], browser: bool = False) -> bytes:
+ return encode_message(
+ {
+ "type": MSG_FILE_LIST,
+ "files": files,
+ "browser": bool(browser),
+ },
+ )
+
+
+def make_file_list_request(browser: bool = False) -> bytes:
+ return encode_message({"type": MSG_FILE_LIST_REQUEST, "browser": bool(browser)})
+
+
+def make_file_request(path: str) -> bytes:
+ return encode_message({"type": MSG_FILE_REQUEST, "path": path})
+
+
+def make_delta_request(path: str, local_blocks: list[str]) -> bytes:
+ return encode_message(
+ {
+ "type": MSG_DELTA_REQUEST,
+ "path": path,
+ "local_blocks": list(local_blocks),
+ },
+ )
+
+
+def make_empty_file(path: str, file_hash: str | None) -> bytes:
+ return encode_message(
+ {
+ "type": MSG_EMPTY_FILE,
+ "path": path,
+ "hash": file_hash,
+ },
+ )
+
+
+def make_file_update(path: str, info: dict[str, Any]) -> bytes:
+ return encode_message(
+ {
+ "type": MSG_FILE_UPDATE,
+ "path": path,
+ "info": info,
+ },
+ )
+
+
+def make_file_deletion(path: str) -> bytes:
+ return encode_message({"type": MSG_FILE_DELETION, "path": path})
+
+
+def decode_metadata_value(value: Any) -> Any:
+ if isinstance(value, bytes):
+ try:
+ return value.decode("utf-8")
+ except UnicodeDecodeError:
+ return value.hex()
+ return value

diff --git a/vendor/rns_filesync/rns_filesync/service.py b/vendor/rns_filesync/rns_filesync/service.py
new file mode 100644
index 00000000..7f34b535
--- /dev/null
+++ b/vendor/rns_filesync/rns_filesync/service.py
@@ -0,0 +1,1074 @@
+"""Embeddable FileSyncService for Reticulum hosts."""
+
+from __future__ import annotations
+
+import contextlib
+import os
+import tempfile
+import threading
+import time
+from collections.abc import Callable
+from typing import Any
+
+import RNS
+
+from rns_filesync import protocol
+from rns_filesync.constants import (
+ ANNOUNCE_INTERVAL_DEFAULT,
+ APP_NAME,
+ ASPECT,
+ BLOCK_SIZE,
+ LINK_TIMEOUT_DEFAULT,
+ PATH_TIMEOUT_DEFAULT,
+ RECONNECT_BASE_INTERVAL,
+ RECONNECT_MAX_INTERVAL,
+ SCAN_INTERVAL,
+)
+from rns_filesync.inventory import (
+ Inventory,
+ decide_sync_action,
+ differing_block_nums,
+ hash_blocks,
+ hash_file,
+)
+from rns_filesync.paths import PathJailError, normalize_relpath, resolve_under_root
+from rns_filesync.peers import (
+ create_outbound_destination,
+ establish_link,
+ hex_hash,
+ parse_hash,
+ peer_id_from_link,
+ resolve_peer_identity,
+ wait_for_path,
+)
+from rns_filesync.permissions import PermissionStore
+from rns_filesync.transfer import (
+ build_delta_payload,
+ commit_received_file,
+ create_empty_file,
+)
+
+
+class FileSyncService:
+ """Peer-to-peer directory sync over Reticulum.
+
+ Host programs inject identity and optionally an existing Reticulum instance.
+ The service never constructs a second Reticulum stack when one already exists.
+ """
+
+ def __init__(
+ self,
+ *,
+ identity,
+ sync_directory: str,
+ storage_dir: str | None = None,
+ reticulum=None,
+ configpath: str | None = None,
+ permissions: PermissionStore | dict | None = None,
+ own_reticulum: bool = False,
+ ):
+ self.identity = identity
+ self.sync_directory = os.path.realpath(os.path.expanduser(sync_directory))
+ self.storage_dir = storage_dir
+ self.configpath = configpath
+ self._provided_reticulum = reticulum
+ self._own_reticulum = own_reticulum
+ self.reticulum = None
+
+ if isinstance(permissions, PermissionStore):
+ self.permissions = permissions
+ elif isinstance(permissions, dict):
+ self.permissions = PermissionStore(permissions)
+ else:
+ self.permissions = PermissionStore()
+
+ self.inventory = Inventory(self.sync_directory)
+ self.destination = None
+
+ self._lock = threading.RLock()
+ self._links: dict[str, Any] = {}
+ self._desired_peers: set[str] = set()
+ self._incoming: dict[str, float] = {}
+ self._outgoing: dict[tuple[str, str], float] = {}
+ self._resources: dict[tuple[str, str], Any] = {}
+ self._browse_results: dict[str, list[dict[str, Any]]] = {}
+ self._browse_events: dict[str, threading.Event] = {}
+ self._connect_attempts: set[str] = set()
+ self._reconnect_backoff: dict[str, float] = {}
+
+ self._running = False
+ self._monitor = False
+ self._announce_interval = ANNOUNCE_INTERVAL_DEFAULT
+ self._stop_event = threading.Event()
+ self._threads: list[threading.Thread] = []
+
+ self.on_peer_connected: Callable[[dict], None] | None = None
+ self.on_peer_disconnected: Callable[[dict], None] | None = None
+ self.on_sync_progress: Callable[[dict], None] | None = None
+ self.on_file_updated: Callable[[dict], None] | None = None
+ self.on_file_deleted: Callable[[dict], None] | None = None
+ self.on_error: Callable[[dict], None] | None = None
+
+ def _emit(self, callback: Callable | None, payload: dict) -> None:
+ if callback is None:
+ return
+ try:
+ callback(payload)
+ except Exception as exc:
+ RNS.log(f"FileSync callback error: {exc}", RNS.LOG_DEBUG)
+
+ def _ensure_reticulum(self):
+ if self.reticulum is not None:
+ return self.reticulum
+ existing = None
+ with contextlib.suppress(Exception):
+ existing = RNS.Reticulum.get_instance()
+ if self._provided_reticulum is not None:
+ self.reticulum = self._provided_reticulum
+ self._own_reticulum = False
+ return self.reticulum
+ if existing is not None:
+ self.reticulum = existing
+ self._own_reticulum = False
+ return self.reticulum
+ self.reticulum = RNS.Reticulum(self.configpath)
+ self._own_reticulum = True
+ return self.reticulum
+
+ def start(
+ self,
+ *,
+ monitor: bool = True,
+ announce_interval: int = ANNOUNCE_INTERVAL_DEFAULT,
+ ) -> str:
+ """Register inbound destination and start background workers."""
+ with self._lock:
+ if self._running:
+ return hex_hash(self.destination.hash)
+
+ os.makedirs(self.sync_directory, exist_ok=True)
+ self._ensure_reticulum()
+ self.inventory.load()
+ self.inventory.scan()
+ self.inventory.save()
+
+ self.destination = RNS.Destination(
+ self.identity,
+ RNS.Destination.IN,
+ RNS.Destination.SINGLE,
+ APP_NAME,
+ ASPECT,
+ )
+ self.destination.set_link_established_callback(self._on_link_established)
+
+ self._running = True
+ self._monitor = monitor
+ self._announce_interval = max(10, int(announce_interval))
+ self._stop_event.clear()
+
+ self.destination.announce()
+ RNS.log(
+ f"FileSync started dest={RNS.prettyhexrep(self.destination.hash)} "
+ f"id={RNS.prettyhexrep(self.identity.hash)} dir={self.sync_directory}",
+ RNS.LOG_NOTICE,
+ )
+
+ self._threads = [
+ threading.Thread(
+ target=self._announce_loop,
+ name="filesync-announce",
+ daemon=True,
+ ),
+ threading.Thread(
+ target=self._reconnect_loop,
+ name="filesync-reconnect",
+ daemon=True,
+ ),
+ ]
+ if monitor:
+ self._threads.append(
+ threading.Thread(
+ target=self._monitor_loop,
+ name="filesync-monitor",
+ daemon=True,
+ ),
+ )
+ for thread in self._threads:
+ thread.start()
+
+ return hex_hash(self.destination.hash)
+
+ def stop(self) -> None:
+ """Tear down links, destination, and worker threads."""
+ with self._lock:
+ if not self._running:
+ return
+ self._running = False
+ self._stop_event.set()
+
+ links = list(self._links.values())
+ self._links.clear()
+ dest = self.destination
+ self.destination = None
+
+ for link in links:
+ with contextlib.suppress(Exception):
+ link.teardown()
+
+ if dest is not None:
+ with contextlib.suppress(Exception):
+ RNS.Transport.deregister_destination(dest)
+
+ for thread in self._threads:
+ thread.join(timeout=2.0)
+ self._threads = []
+ RNS.log("FileSync stopped", RNS.LOG_INFO)
+
+ def get_status(self) -> dict[str, Any]:
+ with self._lock:
+ peers = len(self._links)
+ files = len(self.inventory.snapshot())
+ dest = hex_hash(self.destination.hash) if self.destination else None
+ identity = hex_hash(self.identity.hash)
+ running = self._running
+ return {
+ "running": running,
+ "sync_directory": self.sync_directory,
+ "identity_hash": identity,
+ "destination_hash": dest,
+ "peers": peers,
+ "files": files,
+ "whitelist": self.permissions.enabled,
+ "monitor": self._monitor,
+ }
+
+ def list_peers(self) -> list[dict[str, Any]]:
+ result = []
+ with self._lock:
+ items = list(self._links.items())
+ for peer_id, link in items:
+ result.append(
+ {
+ "peer_id": peer_id,
+ "destination_hash": hex_hash(link.destination.hash),
+ "status": link.status,
+ "permissions": self.permissions.get(peer_id),
+ },
+ )
+ return result
+
+ def list_files(self) -> list[dict[str, Any]]:
+ snap = self.inventory.snapshot()
+ return [
+ {
+ "path": path,
+ "hash": info.get("hash"),
+ "size": info.get("size"),
+ "mtime": info.get("mtime"),
+ }
+ for path, info in sorted(snap.items())
+ ]
+
+ def announce_now(self) -> None:
+ if self.destination is not None:
+ self.destination.announce()
+
+ def connect_peer(
+ self,
+ identity_hash: str | bytes,
+ timeout: float = PATH_TIMEOUT_DEFAULT,
+ ) -> dict[str, Any]:
+ """Connect using identity hash (destination hash accepted as fallback)."""
+ peer_hash = parse_hash(identity_hash)
+ peer_hex = hex_hash(peer_hash)
+
+ with self._lock:
+ for pid, link in self._links.items():
+ if link.status in (RNS.Link.ACTIVE, RNS.Link.HANDSHAKE) and (
+ pid == peer_hex or hex_hash(link.destination.hash) == peer_hex
+ ):
+ return {"ok": True, "peer_id": pid, "reused": True}
+
+ identity, how = resolve_peer_identity(peer_hash)
+ if identity is None:
+ # Path request may populate known destinations for destination hashes.
+ wait_for_path(peer_hash, timeout=min(timeout, 5.0))
+ identity, how = resolve_peer_identity(peer_hash)
+ if identity is None:
+ msg = f"could not recall identity for {peer_hex}"
+ self._emit(self.on_error, {"error": msg})
+ return {"ok": False, "error": msg}
+
+ destination = create_outbound_destination(identity)
+ if not wait_for_path(destination.hash, timeout=timeout):
+ msg = f"path timeout for {peer_hex}"
+ self._emit(self.on_error, {"error": msg})
+ return {"ok": False, "error": msg}
+
+ link = establish_link(
+ destination,
+ established_callback=self._on_link_established,
+ closed_callback=self._on_link_closed,
+ timeout=LINK_TIMEOUT_DEFAULT,
+ )
+ if link is None:
+ msg = f"link failed for {peer_hex}"
+ self._emit(self.on_error, {"error": msg})
+ return {"ok": False, "error": msg}
+
+ # Reveal our identity to the remote so inbound ACL and peer slots work.
+ with contextlib.suppress(Exception):
+ link.identify(self.identity)
+
+ self._configure_link(link)
+ peer_id = peer_id_from_link(link) or hex_hash(identity.hash)
+ with self._lock:
+ self._links[peer_id] = link
+ self._desired_peers.add(hex_hash(identity.hash))
+ self._reconnect_backoff.pop(hex_hash(identity.hash), None)
+
+ self._maybe_exchange_lists(link, peer_id)
+ RNS.log(
+ f"Connected to peer {peer_id} via {how}",
+ RNS.LOG_INFO,
+ )
+ return {"ok": True, "peer_id": peer_id, "via": how}
+
+ def disconnect_peer(self, peer_id: str) -> None:
+ with self._lock:
+ link = self._links.pop(peer_id, None)
+ self._desired_peers.discard(peer_id)
+ if link is not None:
+ with contextlib.suppress(Exception):
+ link.teardown()
+
+ def browse_peer(self, peer_id: str, timeout: float = 10.0) -> list[dict[str, Any]]:
+ link = self._get_link(peer_id)
+ if link is None:
+ return []
+ event = threading.Event()
+ with self._lock:
+ self._browse_events[peer_id] = event
+ self._browse_results.pop(peer_id, None)
+ self._send(link, protocol.make_file_list_request(browser=True))
+ event.wait(timeout=timeout)
+ with self._lock:
+ self._browse_events.pop(peer_id, None)
+ return list(self._browse_results.get(peer_id, []))
+
+ def download_file(self, peer_id: str, path: str) -> dict[str, Any]:
+ link = self._get_link(peer_id)
+ if link is None:
+ return {"ok": False, "error": "peer not connected"}
+ try:
+ safe = normalize_relpath(path)
+ except PathJailError as exc:
+ return {"ok": False, "error": str(exc)}
+ self._request_file(link, safe)
+ return {"ok": True, "path": safe}
+
+ def _get_link(self, peer_id: str):
+ with self._lock:
+ link = self._links.get(peer_id)
+ if link is not None and link.status == RNS.Link.ACTIVE:
+ return link
+ for pid, candidate in self._links.items():
+ if pid.startswith(peer_id) or peer_id.startswith(pid):
+ if candidate.status == RNS.Link.ACTIVE:
+ return candidate
+ return None
+
+ def _peer_key(self, link) -> str | None:
+ return peer_id_from_link(link)
+
+ def _require_perm(self, link, permission: str) -> bool:
+ """Return True if the peer may perform permission (or ACL is open)."""
+ if not self.permissions.enabled:
+ return True
+ peer_id = self._peer_key(link)
+ if not peer_id:
+ return False
+ return self.permissions.check(peer_id, permission)
+
+ def _configure_link(self, link) -> None:
+ link.set_packet_callback(self._on_packet)
+ link.set_resource_strategy(RNS.Link.ACCEPT_APP)
+ link.set_resource_callback(self._on_resource_advert)
+ link.set_resource_started_callback(self._on_resource_started)
+ link.set_resource_concluded_callback(self._on_resource_concluded)
+ if not hasattr(link, "upload_buffers"):
+ link.upload_buffers = {}
+
+ def _on_link_established(self, link) -> None:
+ remote = None
+ with contextlib.suppress(Exception):
+ remote = link.get_remote_identity()
+
+ if remote is None:
+ # Never key inbound links by local destination hash: that collapses
+ # every unidentified peer onto one _links entry.
+ link.set_remote_identified_callback(self._on_remote_identified)
+ link.set_link_closed_callback(self._on_link_closed)
+ if self.permissions.enabled:
+ return
+ self._activate_peer_link(link, None)
+ return
+
+ if self.permissions.enabled and not self.permissions.can_connect(remote.hash):
+ RNS.log(
+ f"Rejecting peer {RNS.prettyhexrep(remote.hash)}",
+ RNS.LOG_WARNING,
+ )
+ link.teardown()
+ return
+
+ self._activate_peer_link(link, remote)
+
+ def _on_remote_identified(self, link, identity) -> None:
+ if self.permissions.enabled and not self.permissions.can_connect(identity.hash):
+ RNS.log(
+ f"Rejecting identified peer {RNS.prettyhexrep(identity.hash)}",
+ RNS.LOG_WARNING,
+ )
+ link.teardown()
+ return
+ real_id = hex_hash(identity.hash)
+ with self._lock:
+ had_link = any(value is link for value in self._links.values())
+ for key, value in list(self._links.items()):
+ if value is link and key != real_id:
+ self._links.pop(key, None)
+ self._links[real_id] = link
+ self._desired_peers.add(real_id)
+ if had_link:
+ self._configure_link(link)
+ self._emit(
+ self.on_peer_connected,
+ {
+ "peer_id": real_id,
+ "permissions": self.permissions.get(real_id),
+ },
+ )
+ self._maybe_exchange_lists(link, real_id)
+ return
+ self._activate_peer_link(link, identity)
+
+ def _activate_peer_link(self, link, remote) -> None:
+ self._configure_link(link)
+ peer_id = peer_id_from_link(link)
+ if peer_id is None and remote is not None:
+ peer_id = hex_hash(remote.hash)
+ provisional = False
+ if peer_id is None:
+ # Unique until remote identity arrives. Do not use destination.hash.
+ peer_id = f"pending:{id(link)}"
+ provisional = True
+ with contextlib.suppress(Exception):
+ link.set_remote_identified_callback(self._on_remote_identified)
+
+ with self._lock:
+ for key, value in list(self._links.items()):
+ if value is link and key != peer_id:
+ self._links.pop(key, None)
+ self._links[peer_id] = link
+ if remote is not None and not provisional:
+ self._desired_peers.add(hex_hash(remote.hash))
+
+ self._emit(
+ self.on_peer_connected,
+ {"peer_id": peer_id, "permissions": self.permissions.get(peer_id)},
+ )
+ if not provisional:
+ self._maybe_exchange_lists(link, peer_id)
+
+ def _on_link_closed(self, link) -> None:
+ peer_id = peer_id_from_link(link)
+ with self._lock:
+ if peer_id and self._links.get(peer_id) is link:
+ self._links.pop(peer_id, None)
+ else:
+ for key, value in list(self._links.items()):
+ if value is link:
+ peer_id = key
+ self._links.pop(key, None)
+ break
+ if peer_id:
+ self._emit(self.on_peer_disconnected, {"peer_id": peer_id})
+
+ def _maybe_exchange_lists(self, link, peer_id: str) -> None:
+ allow = (not self.permissions.enabled) or self.permissions.check(
+ peer_id,
+ "read",
+ )
+ if not allow:
+ RNS.log(
+ f"Skipping file list for {peer_id}: no read permission",
+ RNS.LOG_INFO,
+ )
+ return
+ self._send_file_list(link, browser=False)
+ time.sleep(0.05)
+ self._send(link, protocol.make_file_list_request(browser=False))
+
+ def _send(self, link, payload: bytes) -> None:
+ try:
+ packet = RNS.Packet(link, payload)
+ packet.send()
+ except Exception as exc:
+ RNS.log(f"Packet send failed: {exc}", RNS.LOG_ERROR)
+
+ def _send_file_list(self, link, browser: bool = False) -> None:
+ if not self._require_perm(link, "read"):
+ RNS.log("Denied file list: no read permission", RNS.LOG_WARNING)
+ return
+ files = self.inventory.scan()
+ self.inventory.save()
+ self._send(link, protocol.make_file_list(files, browser=browser))
+
+ def _on_packet(self, message, packet) -> None:
+ try:
+ data = protocol.decode_message(message)
+ except protocol.ProtocolError as exc:
+ RNS.log(f"Bad protocol message: {exc}", RNS.LOG_DEBUG)
+ return
+
+ link = packet.link
+ msg_type = data["type"]
+ if msg_type == protocol.MSG_FILE_LIST:
+ self._handle_file_list(data, link)
+ elif msg_type == protocol.MSG_FILE_LIST_REQUEST:
+ self._send_file_list(link, browser=bool(data.get("browser", False)))
+ elif msg_type == protocol.MSG_FILE_REQUEST:
+ self._handle_file_request(data, link)
+ elif msg_type == protocol.MSG_DELTA_REQUEST:
+ self._handle_delta_request(data, link)
+ elif msg_type == protocol.MSG_EMPTY_FILE:
+ self._handle_empty_file(data, link)
+ elif msg_type == protocol.MSG_FILE_UPDATE:
+ self._handle_file_update(data, link)
+ elif msg_type == protocol.MSG_FILE_DELETION:
+ self._handle_file_deletion(data, link)
+
+ def _handle_file_list(self, data: dict, link) -> None:
+ # Inbound inventory drives local writes. Require write when ACL is on.
+ if not self._require_perm(link, "write"):
+ RNS.log("Ignored peer file list: no write permission", RNS.LOG_WARNING)
+ return
+
+ peer_files = data.get("files") or {}
+ browser = bool(data.get("browser", False))
+ peer_id = self._peer_key(link) or "unknown"
+
+ if browser:
+ remote = []
+ for path, info in peer_files.items():
+ try:
+ safe = normalize_relpath(str(path))
+ except PathJailError:
+ continue
+ if not isinstance(info, dict):
+ continue
+ remote.append(
+ {
+ "path": safe,
+ "size": info.get("size", 0),
+ "hash": info.get("hash"),
+ "mtime": info.get("mtime"),
+ },
+ )
+ with self._lock:
+ self._browse_results[peer_id] = remote
+ event = self._browse_events.get(peer_id)
+ if event:
+ event.set()
+ return
+
+ local = self.inventory.scan()
+ for filepath, peer_info in peer_files.items():
+ if not isinstance(peer_info, dict):
+ continue
+ try:
+ safe = normalize_relpath(str(filepath))
+ except PathJailError:
+ RNS.log(
+ f"Rejected unsafe path from peer: {filepath!r}",
+ RNS.LOG_WARNING,
+ )
+ continue
+ action = decide_sync_action(local.get(safe), peer_info)
+ if action == "request_full":
+ self._request_file(link, safe)
+ elif action == "request_delta":
+ self._request_delta(link, safe)
+
+ def _request_file(self, link, filepath: str) -> None:
+ with self._lock:
+ if filepath in self._incoming:
+ return
+ self._incoming[filepath] = time.time()
+ self._send(link, protocol.make_file_request(filepath))
+
+ def _request_delta(self, link, filepath: str) -> None:
+ with self._lock:
+ if filepath in self._incoming:
+ return
+ self._incoming[filepath] = time.time()
+ try:
+ full_path = resolve_under_root(self.sync_directory, filepath)
+ local_blocks = hash_blocks(full_path) if os.path.exists(full_path) else []
+ except PathJailError:
+ with self._lock:
+ self._incoming.pop(filepath, None)
+ return
+ self._send(
+ link,
+ protocol.make_delta_request(filepath, [b["hash"] for b in local_blocks]),
+ )
+
+ def _begin_outgoing(self, link, filepath: str) -> tuple[str, str] | None:
+ # Never collapse concurrent sends onto a shared "unknown" key.
+ peer_id = self._peer_key(link) or f"link:{id(link)}"
+ key = (peer_id, filepath)
+ with self._lock:
+ started = self._outgoing.get(key)
+ if started is not None and time.time() - started < 86400:
+ return None
+ self._outgoing[key] = time.time()
+ return key
+
+ def _end_outgoing(self, key: tuple[str, str] | None) -> None:
+ if key is None:
+ return
+ with self._lock:
+ self._outgoing.pop(key, None)
+ self._resources.pop(key, None)
+
+ def _handle_file_request(self, data: dict, link) -> None:
+ filepath = data.get("path")
+ if not filepath:
+ return
+ if not self._require_perm(link, "read"):
+ return
+ try:
+ safe = normalize_relpath(str(filepath))
+ full_path = resolve_under_root(self.sync_directory, safe)
+ except PathJailError:
+ return
+
+ if not os.path.isfile(full_path):
+ return
+
+ key = self._begin_outgoing(link, safe)
+ if key is None:
+ return
+
+ file_size = os.path.getsize(full_path)
+ file_hash = self.inventory.get(safe)
+ digest = file_hash.get("hash") if file_hash else hash_file(full_path)
+
+ if file_size == 0:
+ self._send(link, protocol.make_empty_file(safe, digest))
+ self._end_outgoing(key)
+ return
+
+ metadata = {
+ "filepath": safe.encode("utf-8"),
+ "hash": (digest or "").encode("utf-8"),
+ "size": file_size,
+ }
+
+ peer_id = self._peer_key(link) or "unknown"
+
+ def concluded(resource):
+ try:
+ if hasattr(resource, "data") and hasattr(resource.data, "close"):
+ with contextlib.suppress(Exception):
+ resource.data.close()
+ finally:
+ self._end_outgoing(key)
+
+ try:
+ handle = open(full_path, "rb")
+ resource = RNS.Resource(
+ handle,
+ link,
+ metadata=metadata,
+ auto_compress=True,
+ callback=concluded,
+ )
+
+ def progress(res):
+ self._emit(
+ self.on_sync_progress,
+ {
+ "path": safe,
+ "direction": "send",
+ "progress": res.get_progress(),
+ "peer_id": peer_id,
+ },
+ )
+
+ resource.progress_callback(progress)
+ with self._lock:
+ self._resources[key] = resource
+ except Exception as exc:
+ RNS.log(f"Send failed for {safe}: {exc}", RNS.LOG_ERROR)
+ self._end_outgoing(key)
+
+ def _handle_delta_request(self, data: dict, link) -> None:
+ filepath = data.get("path")
+ peer_blocks = data.get("local_blocks") or []
+ if not filepath:
+ return
+ if not self._require_perm(link, "read"):
+ return
+ if not isinstance(peer_blocks, list):
+ return
+ try:
+ safe = normalize_relpath(str(filepath))
+ full_path = resolve_under_root(self.sync_directory, safe)
+ except PathJailError:
+ return
+
+ if not os.path.isfile(full_path):
+ return
+
+ key = self._begin_outgoing(link, safe)
+ if key is None:
+ return
+
+ local_blocks = hash_blocks(full_path)
+ # Only accept string hashes from the peer.
+ safe_peer_blocks = [b for b in peer_blocks if isinstance(b, str)]
+ blocks_to_send = differing_block_nums(local_blocks, safe_peer_blocks)
+ if len(blocks_to_send) == len(local_blocks):
+ self._end_outgoing(key)
+ self._handle_file_request({"path": safe}, link)
+ return
+
+ info = self.inventory.get(safe) or {}
+ digest = info.get("hash") or hash_file(full_path)
+ size = info.get("size")
+ if size is None:
+ size = os.path.getsize(full_path)
+
+ payload = build_delta_payload(full_path, blocks_to_send, BLOCK_SIZE)
+ metadata = {
+ "filepath": safe.encode("utf-8"),
+ "hash": (digest or "").encode("utf-8"),
+ "mode": "delta",
+ "blocks": blocks_to_send,
+ "size": size,
+ }
+
+ tmp = tempfile.NamedTemporaryFile(prefix=".rns-delta-", delete=False)
+ tmp_path = tmp.name
+ try:
+ tmp.write(payload)
+ tmp.flush()
+ tmp.seek(0)
+ except Exception:
+ tmp.close()
+ with contextlib.suppress(OSError):
+ os.unlink(tmp_path)
+ raise
+
+ def concluded(resource):
+ with contextlib.suppress(Exception):
+ tmp.close()
+ with contextlib.suppress(OSError):
+ os.unlink(tmp_path)
+ self._end_outgoing(key)
+
+ try:
+ RNS.Resource(
+ tmp,
+ link,
+ metadata=metadata,
+ callback=concluded,
+ )
+ except Exception as exc:
+ RNS.log(f"Delta send failed for {safe}: {exc}", RNS.LOG_ERROR)
+ with contextlib.suppress(Exception):
+ tmp.close()
+ with contextlib.suppress(OSError):
+ os.unlink(tmp_path)
+ self._end_outgoing(key)
+
+ def _on_resource_advert(self, advertisement) -> bool:
+ try:
+ if self.permissions.enabled:
+ sender = advertisement.link.get_remote_identity()
+ if sender is None:
+ return False
+ return self.permissions.check(sender.hash, "write")
+ return True
+ except Exception:
+ return False
+
+ def _on_resource_started(self, resource) -> None:
+ def progress(res):
+ path = None
+ if res.metadata and isinstance(res.metadata, dict):
+ path = protocol.decode_metadata_value(res.metadata.get("filepath"))
+ self._emit(
+ self.on_sync_progress,
+ {
+ "path": path,
+ "direction": "receive",
+ "progress": res.get_progress(),
+ },
+ )
+
+ resource.progress_callback(progress)
+
+ def _on_resource_concluded(self, resource) -> None:
+ if resource.status != RNS.Resource.COMPLETE:
+ filepath = None
+ if resource.metadata and isinstance(resource.metadata, dict):
+ filepath = protocol.decode_metadata_value(
+ resource.metadata.get("filepath"),
+ )
+ if filepath:
+ with self._lock:
+ self._incoming.pop(filepath, None)
+ RNS.log(f"Incoming resource failed status={resource.status}", RNS.LOG_ERROR)
+ return
+
+ if not resource.metadata or not isinstance(resource.metadata, dict):
+ return
+ if "filepath" not in resource.metadata:
+ return
+
+ filepath = protocol.decode_metadata_value(resource.metadata.get("filepath"))
+ expected_hash = (
+ protocol.decode_metadata_value(resource.metadata.get("hash")) or None
+ )
+ if isinstance(expected_hash, str) and not expected_hash.strip():
+ expected_hash = None
+ mode = protocol.decode_metadata_value(resource.metadata.get("mode")) or "full"
+ expected_size = resource.metadata.get("size")
+ blocks = resource.metadata.get("blocks") or []
+
+ if not self._require_perm(resource.link, "write"):
+ with self._lock:
+ self._incoming.pop(str(filepath), None)
+ RNS.log(
+ "Rejected resource: no write permission or unidentified peer",
+ RNS.LOG_WARNING,
+ )
+ return
+
+ try:
+ safe = normalize_relpath(str(filepath))
+ if not isinstance(blocks, list):
+ raise ValueError("invalid delta block list")
+ safe_blocks = [
+ b for b in blocks if isinstance(b, int) and not isinstance(b, bool)
+ ]
+ commit_received_file(
+ self.sync_directory,
+ safe,
+ mode="delta" if mode == "delta" else "full",
+ resource_data=resource.data,
+ block_nums=safe_blocks if mode == "delta" else None,
+ expected_hash=expected_hash,
+ expected_size=int(expected_size) if expected_size is not None else None,
+ require_hash=True,
+ )
+ info = self.inventory.update_from_path(safe)
+ self.inventory.save()
+ with self._lock:
+ self._incoming.pop(safe, None)
+ self._emit(self.on_file_updated, {"path": safe, "info": info})
+ self._broadcast_update(safe, exclude_link=resource.link)
+ RNS.log(f"Received {safe}", RNS.LOG_INFO)
+ except Exception as exc:
+ with self._lock:
+ self._incoming.pop(str(filepath), None)
+ RNS.log(f"Failed to commit {filepath}: {exc}", RNS.LOG_ERROR)
+ self._emit(self.on_error, {"error": str(exc), "path": filepath})
+
+ def _handle_empty_file(self, data: dict, link) -> None:
+ filepath = data.get("path")
+ expected_hash = data.get("hash")
+ if not filepath:
+ return
+ if not self._require_perm(link, "write"):
+ return
+ try:
+ safe = normalize_relpath(str(filepath))
+ create_empty_file(self.sync_directory, safe)
+ full = resolve_under_root(self.sync_directory, safe)
+ actual = hash_file(full)
+ if expected_hash and actual != expected_hash:
+ with contextlib.suppress(OSError):
+ os.remove(full)
+ RNS.log(f"Empty file hash mismatch for {safe}", RNS.LOG_WARNING)
+ return
+ info = self.inventory.update_from_path(safe)
+ self.inventory.save()
+ self._emit(self.on_file_updated, {"path": safe, "info": info})
+ self._broadcast_update(safe, exclude_link=link)
+ except Exception as exc:
+ RNS.log(f"Empty file handle failed: {exc}", RNS.LOG_ERROR)
+
+ def _handle_file_update(self, data: dict, link) -> None:
+ filepath = data.get("path")
+ peer_info = data.get("info")
+ if not filepath or not isinstance(peer_info, dict):
+ return
+ if not self._require_perm(link, "write"):
+ return
+ try:
+ safe = normalize_relpath(str(filepath))
+ except PathJailError:
+ return
+ local = self.inventory.get(safe)
+ action = decide_sync_action(local, peer_info)
+ if action == "request_full":
+ self._request_file(link, safe)
+ elif action == "request_delta":
+ full = os.path.join(self.sync_directory, safe)
+ if os.path.exists(full):
+ self._request_delta(link, safe)
+ else:
+ self._request_file(link, safe)
+
+ def _handle_file_deletion(self, data: dict, link) -> None:
+ filepath = data.get("path")
+ if not filepath:
+ return
+ if not self._require_perm(link, "delete"):
+ return
+ try:
+ safe = normalize_relpath(str(filepath))
+ full = resolve_under_root(self.sync_directory, safe)
+ except PathJailError:
+ return
+ if os.path.isfile(full):
+ with contextlib.suppress(OSError):
+ os.remove(full)
+ self.inventory.remove_file(safe)
+ self.inventory.save()
+ self._emit(self.on_file_deleted, {"path": safe})
+ self._broadcast_deletion(safe, exclude_link=link)
+
+ def _broadcast_update(self, filepath: str, exclude_link=None) -> None:
+ info = self.inventory.get(filepath)
+ if not info:
+ return
+ payload = protocol.make_file_update(filepath, info)
+ with self._lock:
+ links = list(self._links.values())
+ for link in links:
+ if link is exclude_link or link.status != RNS.Link.ACTIVE:
+ continue
+ self._send(link, payload)
+
+ def _broadcast_deletion(self, filepath: str, exclude_link=None) -> None:
+ payload = protocol.make_file_deletion(filepath)
+ with self._lock:
+ links = list(self._links.values())
+ for link in links:
+ if link is exclude_link or link.status != RNS.Link.ACTIVE:
+ continue
+ self._send(link, payload)
+
+ def _announce_loop(self) -> None:
+ last = time.time()
+ while not self._stop_event.wait(10):
+ if time.time() - last >= self._announce_interval:
+ with contextlib.suppress(Exception):
+ if self.destination is not None:
+ self.destination.announce()
+ last = time.time()
+
+ def _monitor_loop(self) -> None:
+ while not self._stop_event.wait(SCAN_INTERVAL):
+ try:
+ self._monitor_once()
+ except Exception as exc:
+ RNS.log(f"Monitor error: {exc}", RNS.LOG_DEBUG)
+
+ def _monitor_once(self) -> None:
+ previous = self.inventory.snapshot()
+ current = self.inventory.scan()
+ old_paths = set(previous)
+ new_paths = set(current)
+ added = new_paths - old_paths
+ removed = old_paths - new_paths
+ modified = [
+ path
+ for path in (old_paths & new_paths)
+ if previous[path].get("hash") != current[path].get("hash")
+ ]
+ if not (added or removed or modified):
+ return
+ self.inventory.save()
+ for path in added:
+ self._broadcast_update(path)
+ self._emit(
+ self.on_file_updated,
+ {"path": path, "info": current[path], "reason": "added"},
+ )
+ for path in modified:
+ self._broadcast_update(path)
+ self._emit(
+ self.on_file_updated,
+ {"path": path, "info": current[path], "reason": "modified"},
+ )
+ for path in removed:
+ self._broadcast_deletion(path)
+ self._emit(self.on_file_deleted, {"path": path, "reason": "removed"})
+
+ def _reconnect_loop(self) -> None:
+ while not self._stop_event.wait(RECONNECT_BASE_INTERVAL):
+ try:
+ self._reconnect_once()
+ except Exception as exc:
+ RNS.log(f"Reconnect error: {exc}", RNS.LOG_DEBUG)
+
+ def _reconnect_once(self) -> None:
+ with self._lock:
+ desired = list(self._desired_peers)
+ connected = set()
+ for peer_id, link in self._links.items():
+ if link.status in (RNS.Link.ACTIVE, RNS.Link.HANDSHAKE):
+ connected.add(peer_id)
+
+ now = time.time()
+ for peer_id in desired:
+ if peer_id in connected:
+ with self._lock:
+ self._reconnect_backoff.pop(peer_id, None)
+ continue
+ with self._lock:
+ if peer_id in self._connect_attempts:
+ continue
+ next_at = self._reconnect_backoff.get(peer_id, 0)
+ if now < next_at:
+ continue
+ self._connect_attempts.add(peer_id)
+
+ def job(target=peer_id):
+ try:
+ result = self.connect_peer(target, timeout=PATH_TIMEOUT_DEFAULT)
+ with self._lock:
+ if result.get("ok"):
+ self._reconnect_backoff.pop(target, None)
+ else:
+ prev = self._reconnect_backoff.get(
+ target,
+ RECONNECT_BASE_INTERVAL,
+ )
+ delay = min(
+ max(prev * 2, RECONNECT_BASE_INTERVAL),
+ RECONNECT_MAX_INTERVAL,
+ )
+ self._reconnect_backoff[target] = time.time() + delay
+ finally:
+ with self._lock:
+ self._connect_attempts.discard(target)
+
+ threading.Thread(target=job, daemon=True).start()

diff --git a/vendor/rns_filesync/rns_filesync/transfer.py b/vendor/rns_filesync/rns_filesync/transfer.py
new file mode 100644
index 00000000..83e9a91e
--- /dev/null
+++ b/vendor/rns_filesync/rns_filesync/transfer.py
@@ -0,0 +1,164 @@
+"""File transfer helpers: atomic write and delta apply."""
+
+from __future__ import annotations
+
+import os
+import tempfile
+from typing import BinaryIO
+
+from rns_filesync.constants import BLOCK_SIZE
+from rns_filesync.inventory import hash_file
+from rns_filesync.paths import PathJailError, resolve_under_root
+
+# Refuse absurd seeks that could sparse-allocate huge files.
+MAX_DELTA_BLOCK_NUM = 1_048_576
+
+
+def ensure_parent_dir(path: str) -> None:
+ parent = os.path.dirname(path)
+ if parent:
+ os.makedirs(parent, exist_ok=True)
+
+
+def atomic_replace_file(root: str, relpath: str, source_path: str) -> str:
+ """Move source_path into jailed destination via os.replace."""
+ dest = resolve_under_root(root, relpath)
+ ensure_parent_dir(dest)
+ os.replace(source_path, dest)
+ return dest
+
+
+def write_bytes_atomic(root: str, relpath: str, data: bytes) -> str:
+ """Write bytes to a temp file under root then replace into place."""
+ dest = resolve_under_root(root, relpath)
+ ensure_parent_dir(dest)
+ fd, tmp_path = tempfile.mkstemp(
+ prefix=".rns-xfer-",
+ suffix=".tmp",
+ dir=os.path.dirname(dest) or root,
+ )
+ try:
+ with os.fdopen(fd, "wb") as handle:
+ handle.write(data)
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(tmp_path, dest)
+ except Exception:
+ if os.path.exists(tmp_path):
+ os.unlink(tmp_path)
+ raise
+ return dest
+
+
+def create_empty_file(root: str, relpath: str) -> str:
+ return write_bytes_atomic(root, relpath, b"")
+
+
+def apply_delta_blocks(
+ root: str,
+ relpath: str,
+ block_nums: list[int],
+ data_stream: BinaryIO,
+ *,
+ expected_size: int | None = None,
+ block_size: int = BLOCK_SIZE,
+) -> str:
+ """Apply ordered delta blocks to a file under root.
+
+ Creates the file if missing. Truncates to expected_size when provided.
+ """
+ dest = resolve_under_root(root, relpath)
+ ensure_parent_dir(dest)
+ for block_num in block_nums:
+ if not isinstance(block_num, int) or isinstance(block_num, bool):
+ raise PathJailError("invalid block number")
+ if block_num < 0 or block_num > MAX_DELTA_BLOCK_NUM:
+ raise PathJailError("block number out of range")
+ if expected_size is not None:
+ if (
+ not isinstance(expected_size, int)
+ or isinstance(expected_size, bool)
+ or expected_size < 0
+ or expected_size > (MAX_DELTA_BLOCK_NUM + 1) * block_size
+ ):
+ raise PathJailError("expected size out of range")
+ mode = "r+b" if os.path.exists(dest) else "w+b"
+ with open(dest, mode) as handle:
+ for block_num in block_nums:
+ block_data = data_stream.read(block_size)
+ if not block_data:
+ break
+ handle.seek(block_num * block_size)
+ handle.write(block_data)
+ if expected_size is not None:
+ handle.truncate(expected_size)
+ return dest
+
+
+def build_delta_payload(
+ filepath: str,
+ block_nums: list[int],
+ block_size: int = BLOCK_SIZE,
+) -> bytes:
+ """Concatenate selected blocks from filepath into one payload."""
+ chunks: list[bytes] = []
+ with open(filepath, "rb") as handle:
+ for block_num in block_nums:
+ if not isinstance(block_num, int) or isinstance(block_num, bool):
+ raise PathJailError("invalid block number")
+ if block_num < 0 or block_num > MAX_DELTA_BLOCK_NUM:
+ raise PathJailError("block number out of range")
+ handle.seek(block_num * block_size)
+ chunks.append(handle.read(block_size))
+ return b"".join(chunks)
+
+
+def verify_file_hash(filepath: str, expected_hash: str | None) -> bool:
+ if not expected_hash:
+ return False
+ actual = hash_file(filepath)
+ return actual == expected_hash
+
+
+def commit_received_file(
+ root: str,
+ relpath: str,
+ *,
+ mode: str,
+ resource_data,
+ block_nums: list[int] | None = None,
+ expected_hash: str | None = None,
+ expected_size: int | None = None,
+ require_hash: bool = True,
+) -> str:
+ """Commit a received Resource payload into the sync root."""
+ if require_hash and not expected_hash:
+ raise ValueError(f"missing content hash for {relpath}")
+
+ if mode == "delta":
+ if block_nums is None:
+ raise ValueError("delta mode requires block_nums")
+ resource_data.seek(0)
+ dest = apply_delta_blocks(
+ root,
+ relpath,
+ block_nums,
+ resource_data,
+ expected_size=expected_size,
+ )
+ else:
+ source_name = getattr(resource_data, "name", None)
+ if source_name and os.path.isfile(source_name):
+ dest = atomic_replace_file(root, relpath, source_name)
+ else:
+ resource_data.seek(0)
+ dest = write_bytes_atomic(root, relpath, resource_data.read())
+
+ if require_hash or expected_hash:
+ if not verify_file_hash(dest, expected_hash):
+ try:
+ os.remove(dest)
+ except OSError:
+ pass
+ raise ValueError(f"hash mismatch for {relpath}")
+ return dest

diff --git a/vendor/rns_filesync/scripts/bake_meta.py b/vendor/rns_filesync/scripts/bake_meta.py
new file mode 100644
index 00000000..3bf896dd
--- /dev/null
+++ b/vendor/rns_filesync/scripts/bake_meta.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+"""Bake version, build date, and git commit into rns_filesync/_meta.py."""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+META = ROOT / "rns_filesync" / "_meta.py"
+PYPROJECT = ROOT / "pyproject.toml"
+
+TEMPLATE = '''"""Package version and build metadata.
+
+Values are overwritten by make meta / make build when building from git.
+Defaults keep editable installs and tests working without a Makefile bake step.
+"""
+
+from __future__ import annotations
+
+__version__ = {version!r}
+
+BUILD_DATE = {build_date!r}
+
+GIT_COMMIT = {commit!r}
+
+GIT_DIRTY = {dirty!r}
+
+
+def version_string() -> str:
+ """Single-line version for CLI -v / --version."""
+ parts = [f"rns-filesync {{__version__}}"]
+ if BUILD_DATE and BUILD_DATE != "unknown":
+ parts.append(f"built {{BUILD_DATE}}")
+ if GIT_COMMIT and GIT_COMMIT != "unknown":
+ dirty = "+" if GIT_DIRTY in {{"1", "true", "yes", "dirty"}} else ""
+ parts.append(f"commit {{GIT_COMMIT}}{{dirty}}")
+ return " | ".join(parts)
+
+
+def version_info() -> dict[str, str]:
+ return {{
+ "version": __version__,
+ "build_date": BUILD_DATE,
+ "git_commit": GIT_COMMIT,
+ "git_dirty": GIT_DIRTY,
+ }}
+'''
+
+
+def _version() -> str:
+ if "VERSION" in os.environ:
+ return os.environ["VERSION"]
+ try:
+ import tomllib
+
+ data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))
+ return str(data["project"]["version"])
+ except Exception:
+ from rns_filesync._meta import __version__
+
+ return __version__
+
+
+def _git_commit() -> str:
+ if "GIT_COMMIT" in os.environ:
+ return os.environ["GIT_COMMIT"]
+ try:
+ return subprocess.check_output(
+ ["git", "rev-parse", "--short", "HEAD"],
+ cwd=ROOT,
+ stderr=subprocess.DEVNULL,
+ text=True,
+ ).strip()
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ return "unknown"
+
+
+def _git_dirty() -> str:
+ if "GIT_DIRTY" in os.environ:
+ return os.environ["GIT_DIRTY"]
+ try:
+ subprocess.check_call(
+ ["git", "diff", "--quiet"],
+ cwd=ROOT,
+ stderr=subprocess.DEVNULL,
+ )
+ return "0"
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ return "1"
+
+
+def _build_date() -> str:
+ if "BUILD_DATE" in os.environ:
+ return os.environ["BUILD_DATE"]
+ from datetime import datetime, timezone
+
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def main() -> int:
+ version = _version()
+ build_date = _build_date()
+ commit = _git_commit()
+ dirty = _git_dirty()
+ META.write_text(
+ TEMPLATE.format(
+ version=version,
+ build_date=build_date,
+ commit=commit,
+ dirty=dirty,
+ ),
+ encoding="utf-8",
+ )
+ print(f"Baked version={version} date={build_date} commit={commit} dirty={dirty}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())

diff --git a/vendor/rns_filesync/sideband/rns_filesync_command.py b/vendor/rns_filesync/sideband/rns_filesync_command.py
new file mode 100644
index 00000000..ddb30c21
--- /dev/null
+++ b/vendor/rns_filesync/sideband/rns_filesync_command.py
@@ -0,0 +1,117 @@
+# Sideband command plugin for RNS FileSync.
+#
+# Drop this file into Sideband's plugins directory and enable command plugins.
+# Requires the matching service plugin (rns_filesync_service.py) to be loaded.
+#
+# LXMF usage examples (from a trusted peer that can send commands):
+# filesync status
+# filesync peers
+# filesync files
+# filesync announce
+# filesync connect <identity_hash>
+# filesync disconnect <peer_id>
+
+from __future__ import annotations
+
+import RNS
+
+
+class RnsFilesyncCommandPlugin(SidebandCommandPlugin):
+ command_name = "filesync"
+
+ def start(self):
+ RNS.log("RNS FileSync Sideband command plugin starting...", RNS.LOG_NOTICE)
+ super().start()
+
+ def stop(self):
+ super().stop()
+
+ def _reply(self, text: str, destination):
+ self.get_sideband().send_message(
+ text,
+ destination,
+ False,
+ skip_fields=True,
+ no_display=True,
+ )
+
+ def _get_service(self):
+ sideband = self.get_sideband()
+ plugins = getattr(sideband, "active_service_plugins", {}) or {}
+ plugin = plugins.get("rns_filesync")
+ if plugin is None:
+ return None
+ getter = getattr(plugin, "get_filesync", None)
+ if callable(getter):
+ return getter()
+ return getattr(plugin, "service", None)
+
+ def handle_command(self, arguments, lxm):
+ requestor = lxm.source_hash
+ args = [str(a) for a in (arguments or [])]
+ if not args:
+ self._reply(
+ "Usage: filesync status|peers|files|announce|"
+ "connect <hash>|disconnect <id>",
+ requestor,
+ )
+ return
+
+ service = self._get_service()
+ if service is None:
+ self._reply(
+ "RNS FileSync service is not running. "
+ "Enable service plugins and install rns_filesync_service.py.",
+ requestor,
+ )
+ return
+
+ cmd = args[0].lower()
+ try:
+ if cmd == "status":
+ status = service.get_status()
+ lines = [f"{key}={value}" for key, value in status.items()]
+ self._reply("\n".join(lines), requestor)
+ elif cmd == "peers":
+ peers = service.list_peers()
+ if not peers:
+ self._reply("No connected peers", requestor)
+ return
+ lines = []
+ for peer in peers:
+ lines.append(
+ f"{peer.get('peer_id')} status={peer.get('status')} "
+ f"dest={peer.get('destination_hash')}",
+ )
+ self._reply("\n".join(lines), requestor)
+ elif cmd == "files":
+ files = service.list_files()
+ if not files:
+ self._reply("No files in sync inventory", requestor)
+ return
+ lines = [
+ f"{item.get('path')}\t{item.get('size')}\t{item.get('hash')}"
+ for item in files
+ ]
+ self._reply("\n".join(lines), requestor)
+ elif cmd == "announce":
+ service.announce_now()
+ self._reply("Announced", requestor)
+ elif cmd == "connect" and len(args) >= 2:
+ result = service.connect_peer(args[1])
+ self._reply(str(result), requestor)
+ elif cmd == "disconnect" and len(args) >= 2:
+ service.disconnect_peer(args[1])
+ self._reply(f"Disconnected {args[1]}", requestor)
+ else:
+ self._reply(
+ "Unknown command. Use: status peers files announce "
+ "connect <hash> disconnect <id>",
+ requestor,
+ )
+ except Exception as exc:
+ RNS.log(f"filesync command error: {exc}", RNS.LOG_ERROR)
+ self._reply(f"Error: {exc}", requestor)
+
+
+plugin_class = RnsFilesyncCommandPlugin

diff --git a/vendor/rns_filesync/sideband/rns_filesync_service.py b/vendor/rns_filesync/sideband/rns_filesync_service.py
new file mode 100644
index 00000000..9a06f23d
--- /dev/null
+++ b/vendor/rns_filesync/sideband/rns_filesync_service.py
@@ -0,0 +1,142 @@
+# Sideband service plugin for RNS FileSync.
+#
+# Drop this file into Sideband's plugins directory and enable service plugins.
+# Requires the rns-filesync package installed in the same Python environment
+# as Sideband (pip, pipx, or pip-rns).
+#
+# Uses Sideband's identity and Reticulum instance. Sync directory, peers, and
+# ACL come from ~/.rns_filesync/config (same as the CLI).
+
+from __future__ import annotations
+
+import os
+import threading
+import time
+
+import RNS
+
+from rns_filesync.cli import build_permissions
+from rns_filesync.config import config_get, load_config, parse_csv_hashes
+from rns_filesync.constants import ANNOUNCE_INTERVAL_DEFAULT
+from rns_filesync.service import FileSyncService
+
+
+class RnsFilesyncServicePlugin(SidebandServicePlugin):
+ service_name = "rns_filesync"
+
+ def __init__(self, sideband_core):
+ super().__init__(sideband_core)
+ self.service = None
+ self._ready = threading.Event()
+ self._boot_thread = None
+
+ def start(self):
+ RNS.log("RNS FileSync Sideband service plugin starting...", RNS.LOG_NOTICE)
+ self._boot_thread = threading.Thread(
+ target=self._boot_filesync,
+ name="rns-filesync-sideband-boot",
+ daemon=True,
+ )
+ self._boot_thread.start()
+ super().start()
+
+ def stop(self):
+ if self.service is not None:
+ try:
+ self.service.stop()
+ except Exception as exc:
+ RNS.log(f"RNS FileSync stop error: {exc}", RNS.LOG_ERROR)
+ self.service = None
+ self._ready.clear()
+ super().stop()
+
+ def get_filesync(self):
+ """Return the running FileSyncService, or None if not ready."""
+ if not self._ready.is_set():
+ return None
+ return self.service
+
+ def _wait_for_sideband(self, timeout: float = 120.0):
+ sideband = self.get_sideband()
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ identity = getattr(sideband, "identity", None)
+ reticulum = getattr(sideband, "reticulum", None)
+ if identity is not None and reticulum is not None:
+ return identity, reticulum
+ existing = None
+ try:
+ existing = RNS.Reticulum.get_instance()
+ except Exception:
+ existing = None
+ if identity is not None and existing is not None:
+ return identity, existing
+ time.sleep(0.5)
+ raise TimeoutError("Sideband identity/Reticulum not ready for FileSync")
+
+ def _boot_filesync(self):
+ try:
+ identity, reticulum = self._wait_for_sideband()
+ config_dir, config = load_config(None)
+
+ directory = config_get(config, "filesync", "directory", None)
+ if not directory:
+ sideband = self.get_sideband()
+ app_dir = getattr(sideband, "app_dir", None) or os.path.expanduser(
+ "~/.config/sideband",
+ )
+ directory = os.path.join(app_dir, "filesync")
+ directory = os.path.realpath(os.path.expanduser(str(directory)))
+ os.makedirs(directory, exist_ok=True)
+
+ raw_interval = config_get(
+ config,
+ "filesync",
+ "announce_interval",
+ ANNOUNCE_INTERVAL_DEFAULT,
+ )
+ try:
+ announce_interval = int(raw_interval)
+ except (TypeError, ValueError):
+ announce_interval = ANNOUNCE_INTERVAL_DEFAULT
+
+ permissions = build_permissions(
+ config=config,
+ sync_directory=directory,
+ allowed_path=None,
+ allow_args=None,
+ perms_shorthand="rwd",
+ )
+
+ self.service = FileSyncService(
+ identity=identity,
+ sync_directory=directory,
+ reticulum=reticulum,
+ permissions=permissions,
+ own_reticulum=False,
+ )
+ dest = self.service.start(
+ monitor=True,
+ announce_interval=announce_interval,
+ )
+ self._ready.set()
+
+ RNS.log(
+ f"RNS FileSync ready dest={dest} dir={directory} config={config_dir}",
+ RNS.LOG_NOTICE,
+ )
+
+ peers = parse_csv_hashes(config_get(config, "filesync", "peers", None))
+ if peers:
+ time.sleep(1.0)
+ for peer in peers:
+ result = self.service.connect_peer(peer)
+ RNS.log(f"RNS FileSync connect {peer}: {result}", RNS.LOG_INFO)
+ except Exception as exc:
+ RNS.log(f"RNS FileSync Sideband plugin failed: {exc}", RNS.LOG_ERROR)
+ RNS.trace_exception(exc)
+ self.service = None
+ self._ready.clear()
+
+
+plugin_class = RnsFilesyncServicePlugin

diff --git a/vendor/rns_filesync/tests/__init__.py b/vendor/rns_filesync/tests/__init__.py
new file mode 100644
index 00000000..e69de29b

diff --git a/vendor/rns_filesync/tests/acceptance/test_acceptance_policy.py b/vendor/rns_filesync/tests/acceptance/test_acceptance_policy.py
new file mode 100644
index 00000000..e0ba9536
--- /dev/null
+++ b/vendor/rns_filesync/tests/acceptance/test_acceptance_policy.py
@@ -0,0 +1,36 @@
+"""Acceptance tests for ACL and sync decision policy."""
+
+import pytest
+
+from rns_filesync.inventory import decide_sync_action
+from rns_filesync.permissions import PermissionStore
+
+pytestmark = pytest.mark.acceptance
+
+
+@pytest.mark.parametrize(
+ ("local", "peer", "expected"),
+ [
+ (None, {"hash": "1", "size": 10}, "request_full"),
+ ({"hash": "1", "size": 10}, {"hash": "1", "size": 10}, "skip"),
+ ({"hash": "1", "size": 10}, {"hash": "2", "size": 10}, "request_delta"),
+ ({"hash": "1", "size": 0}, {"hash": "2", "size": 0}, "request_full"),
+ ({"hash": "1"}, None, "ignore"),
+ ],
+)
+def test_sync_decision_matrix(local, peer, expected):
+ assert decide_sync_action(local, peer) == expected
+
+
+def test_acl_matrix():
+ store = PermissionStore()
+ reader = "11" * 16
+ writer = "22" * 16
+ store.add_rule(f"r:{reader}")
+ store.add_rule(f"rwd:{writer}")
+
+ assert store.check(reader, "read")
+ assert not store.check(reader, "write")
+ assert not store.check(reader, "delete")
+ assert store.check(writer, "delete")
+ assert not store.can_connect("33" * 16)

diff --git a/vendor/rns_filesync/tests/acceptance/test_auth_bypass.py b/vendor/rns_filesync/tests/acceptance/test_auth_bypass.py
new file mode 100644
index 00000000..360cf5b6
--- /dev/null
+++ b/vendor/rns_filesync/tests/acceptance/test_auth_bypass.py
@@ -0,0 +1,266 @@
+"""Adversarial tests for ACL bypasses, path escapes, and malicious protocol."""
+
+from __future__ import annotations
+
+import io
+import threading
+from types import SimpleNamespace
+
+import pytest
+
+from rns_filesync import protocol
+from rns_filesync.paths import PathJailError, normalize_relpath, resolve_under_root
+from rns_filesync.permissions import PermissionStore
+from rns_filesync.transfer import (
+ MAX_DELTA_BLOCK_NUM,
+ apply_delta_blocks,
+ commit_received_file,
+ write_bytes_atomic,
+)
+
+pytestmark = [pytest.mark.acceptance, pytest.mark.unit]
+
+
+class _FakeLink:
+ def __init__(self, identity_hash: bytes | None):
+ self._identity = None
+ if identity_hash is not None:
+ self._identity = SimpleNamespace(hash=identity_hash)
+ self.destination = SimpleNamespace(hash=b"\x11" * 16)
+ self.torn_down = False
+ self.packets: list[bytes] = []
+
+ def get_remote_identity(self):
+ return self._identity
+
+ def teardown(self):
+ self.torn_down = True
+
+
+def _service_with_acl(tmp_path, rules: list[str]):
+ from rns_filesync.service import FileSyncService
+
+ sync = tmp_path / "sync"
+ sync.mkdir()
+ (sync / "secret.txt").write_text("top-secret")
+ perms = PermissionStore()
+ for rule in rules:
+ perms.add_rule(rule)
+ identity = SimpleNamespace(hash=b"\xaa" * 16)
+ svc = FileSyncService(
+ identity=identity,
+ sync_directory=str(sync),
+ permissions=perms,
+ )
+ svc.inventory.scan()
+ svc.inventory.save()
+ # Avoid real RNS Packet sends.
+ svc._send = lambda link, payload: link.packets.append(payload)
+ return svc, sync
+
+
+def test_read_only_peer_cannot_pull_file_list(tmp_path):
+ reader = b"\x01" * 16
+ svc, _sync = _service_with_acl(tmp_path, [f"r:{reader.hex()}"])
+ # Peer with no write should not drive inbound sync, and stranger cannot list.
+ stranger = _FakeLink(b"\x02" * 16)
+ before = len(stranger.packets)
+ svc._send_file_list(stranger)
+ assert len(stranger.packets) == before
+
+ allowed = _FakeLink(reader)
+ svc._send_file_list(allowed)
+ assert len(allowed.packets) == 1
+ msg = protocol.decode_message(allowed.packets[0])
+ assert msg["type"] == protocol.MSG_FILE_LIST
+ assert "secret.txt" in msg["files"]
+
+
+def test_read_only_peer_cannot_push_file_list_or_update(tmp_path):
+ reader = b"\x01" * 16
+ svc, sync = _service_with_acl(tmp_path, [f"r:{reader.hex()}"])
+ link = _FakeLink(reader)
+ svc._handle_file_list(
+ {
+ "type": "file_list",
+ "files": {"evil.txt": {"hash": "a" * 64, "size": 1, "mtime": 1}},
+ },
+ link,
+ )
+ assert not (sync / "evil.txt").exists()
+ svc._handle_file_update(
+ {"path": "evil.txt", "info": {"hash": "b" * 64, "size": 1}},
+ link,
+ )
+ assert "evil.txt" not in svc._incoming
+
+
+def test_unidentified_peer_denied_when_acl_on(tmp_path):
+ svc, _sync = _service_with_acl(tmp_path, ["r:all"])
+ link = _FakeLink(None)
+ assert not svc._require_perm(link, "read")
+ assert not svc._require_perm(link, "write")
+ before = len(link.packets)
+ svc._send_file_list(link)
+ assert len(link.packets) == before
+ svc._handle_file_request({"path": "secret.txt"}, link)
+ assert len(link.packets) == before
+
+
+def test_blocked_identity_cannot_connect_or_read(tmp_path):
+ peer = b"\x03" * 16
+ svc, _sync = _service_with_acl(tmp_path, ["r:all"])
+ svc.permissions.block(peer)
+ assert not svc.permissions.can_connect(peer)
+ link = _FakeLink(peer)
+ svc._send_file_list(link)
+ assert link.packets == []
+
+
+def test_delete_requires_delete_perm(tmp_path):
+ writer = b"\x04" * 16
+ svc, sync = _service_with_acl(tmp_path, [f"rw:{writer.hex()}"])
+ link = _FakeLink(writer)
+ svc._handle_file_deletion({"path": "secret.txt"}, link)
+ assert (sync / "secret.txt").exists()
+
+
+def test_path_traversal_payloads_rejected():
+ payloads = [
+ "../etc/passwd",
+ "..\\windows\\system32",
+ "a/../../b",
+ "/etc/passwd",
+ "C:\\Windows\\System32",
+ "a\x00b",
+ "",
+ ".",
+ "..",
+ "//evil",
+ "\\\\evil",
+ "foo/../../../etc/shadow",
+ ".rns-filesync.db",
+ "dir/.rns-xfer-temp",
+ ]
+ for payload in payloads:
+ with pytest.raises(PathJailError):
+ normalize_relpath(payload)
+
+
+def test_symlink_escape_blocked(tmp_path):
+ root = tmp_path / "sync"
+ root.mkdir()
+ outside = tmp_path / "outside.txt"
+ outside.write_text("secret")
+ link = root / "escape"
+ try:
+ link.symlink_to(tmp_path)
+ except OSError:
+ pytest.skip("symlinks unavailable")
+ with pytest.raises(PathJailError):
+ resolve_under_root(str(root), "escape/outside.txt")
+
+
+def test_commit_rejects_missing_hash(tmp_path):
+ root = tmp_path / "sync"
+ root.mkdir()
+ with pytest.raises(ValueError, match="missing content hash"):
+ commit_received_file(
+ str(root),
+ "x.bin",
+ mode="full",
+ resource_data=io.BytesIO(b"data"),
+ expected_hash=None,
+ require_hash=True,
+ )
+
+
+def test_commit_rejects_hash_mismatch_and_removes(tmp_path):
+ root = tmp_path / "sync"
+ root.mkdir()
+ with pytest.raises(ValueError, match="hash mismatch"):
+ commit_received_file(
+ str(root),
+ "x.bin",
+ mode="full",
+ resource_data=io.BytesIO(b"data"),
+ expected_hash="0" * 64,
+ require_hash=True,
+ )
+ assert not (root / "x.bin").exists()
+
+
+def test_delta_block_num_cap(tmp_path):
+ root = tmp_path / "sync"
+ root.mkdir()
+ write_bytes_atomic(str(root), "f.bin", b"abc")
+ with pytest.raises(PathJailError):
+ apply_delta_blocks(
+ str(root),
+ "f.bin",
+ [MAX_DELTA_BLOCK_NUM + 1],
+ io.BytesIO(b"x" * 10),
+ )
+ with pytest.raises(PathJailError):
+ apply_delta_blocks(str(root), "f.bin", [-1], io.BytesIO(b"x"))
+ with pytest.raises(PathJailError):
+ apply_delta_blocks(str(root), "f.bin", [True], io.BytesIO(b"x")) # type: ignore[list-item]
+
+
+def test_malicious_file_list_paths_ignored(tmp_path):
+ writer = b"\x05" * 16
+ svc, sync = _service_with_acl(tmp_path, [f"rwd:{writer.hex()}"])
+ link = _FakeLink(writer)
+ svc._handle_file_list(
+ {
+ "files": {
+ "../escape.txt": {"hash": "a" * 64, "size": 1, "mtime": 1},
+ "/etc/passwd": {"hash": "b" * 64, "size": 1, "mtime": 1},
+ "ok.txt": {"hash": "c" * 64, "size": 1, "mtime": 1},
+ },
+ },
+ link,
+ )
+ assert "../escape.txt" not in svc._incoming
+ assert "/etc/passwd" not in svc._incoming
+ assert "ok.txt" in svc._incoming
+ assert not (sync / "escape.txt").exists()
+
+
+def test_acl_none_overrides_all():
+ store = PermissionStore()
+ store.add_rule("r:all")
+ store.add_rule("r:none")
+ assert not store.check("ab" * 16, "read")
+
+
+def test_permission_race_concurrent_checks():
+ store = PermissionStore()
+ peer = "ab" * 16
+ store.add_rule(f"rw:{peer}")
+ errors: list[Exception] = []
+
+ def reader():
+ try:
+ for _ in range(200):
+ assert store.check(peer, "read")
+ assert not store.check("cd" * 16, "read")
+ except Exception as exc:
+ errors.append(exc)
+
+ def writer():
+ try:
+ for i in range(200):
+ store.add_rule(f"r:{i:032x}")
+ store.block(f"{(i + 1):032x}")
+ except Exception as exc:
+ errors.append(exc)
+
+ threads = [threading.Thread(target=reader) for _ in range(4)]
+ threads += [threading.Thread(target=writer) for _ in range(2)]
+ for thread in threads:
+ thread.start()
+ for thread in threads:
+ thread.join()
+ assert errors == []
+ assert store.check(peer, "write")

diff --git a/vendor/rns_filesync/tests/conftest.py b/vendor/rns_filesync/tests/conftest.py
new file mode 100644
index 00000000..7437d022
--- /dev/null
+++ b/vendor/rns_filesync/tests/conftest.py
@@ -0,0 +1 @@
+"""Shared pytest fixtures."""

diff --git a/vendor/rns_filesync/tests/e2e/rns_helpers.py b/vendor/rns_filesync/tests/e2e/rns_helpers.py
new file mode 100644
index 00000000..32206f4c
--- /dev/null
+++ b/vendor/rns_filesync/tests/e2e/rns_helpers.py
@@ -0,0 +1,420 @@
+"""Multi-process Reticulum peer helpers for e2e and live tests."""
+
+from __future__ import annotations
+
+import multiprocessing as mp
+import os
+import queue
+import socket
+import textwrap
+import time
+from pathlib import Path
+
+
+def free_port() -> int:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.bind(("127.0.0.1", 0))
+ return int(sock.getsockname()[1])
+
+
+def write_config(
+ config_dir: Path,
+ *,
+ name: str,
+ listen_port: int | None,
+ peer_port: int | None,
+ shared_port: int | None = None,
+) -> None:
+ config_dir.mkdir(parents=True, exist_ok=True)
+ (config_dir / "storage").mkdir(exist_ok=True)
+ interfaces = []
+ if listen_port is not None:
+ interfaces.append(
+ textwrap.dedent(
+ f"""
+ [[TCP Server {name}]]
+ type = TCPServerInterface
+ enabled = yes
+ listen_ip = 127.0.0.1
+ listen_port = {listen_port}
+ """,
+ ).strip(),
+ )
+ if peer_port is not None:
+ interfaces.append(
+ textwrap.dedent(
+ f"""
+ [[TCP Client {name}]]
+ type = TCPClientInterface
+ enabled = yes
+ target_host = 127.0.0.1
+ target_port = {peer_port}
+ """,
+ ).strip(),
+ )
+ if shared_port is None:
+ shared_port = free_port()
+ config = (
+ textwrap.dedent(
+ f"""
+ [reticulum]
+ enable_transport = Yes
+ share_instance = No
+ shared_instance_port = {shared_port}
+ instance_name = filesync_{name}_{listen_port or peer_port}_{shared_port}
+ panic_on_interface_error = No
+
+ [logging]
+ loglevel = 5
+
+ [interfaces]
+ {chr(10).join(interfaces)}
+ """,
+ ).strip()
+ + "\n"
+ )
+ (config_dir / "config").write_text(config)
+
+
+def wait_until(predicate, timeout: float = 30.0, interval: float = 0.25) -> bool:
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ if predicate():
+ return True
+ time.sleep(interval)
+ return bool(predicate())
+
+
+def _peer_worker(config_dir: str, sync_dir: str, ready_q, cmd_q, result_q) -> None:
+ """Generic FileSync peer process with command queue control."""
+ import RNS
+
+ from rns_filesync.service import FileSyncService
+
+ RNS.loglevel = RNS.LOG_ERROR
+ RNS.Reticulum(config_dir)
+ identity = RNS.Identity()
+ service = FileSyncService(
+ identity=identity,
+ sync_directory=sync_dir,
+ reticulum=RNS.Reticulum.get_instance(),
+ )
+ dest = service.start(monitor=True, announce_interval=8)
+ ready_q.put(
+ {
+ "ok": True,
+ "identity": identity.hash.hex(),
+ "destination": dest,
+ },
+ )
+ while True:
+ try:
+ cmd = cmd_q.get(timeout=2.0)
+ except queue.Empty:
+ service.announce_now()
+ continue
+ if cmd is None or cmd.get("op") == "stop":
+ service.stop()
+ result_q.put({"stopped": True})
+ break
+ if cmd.get("op") == "connect":
+ targets = list(cmd.get("targets") or [])
+ connected = {"ok": False}
+ deadline = time.time() + float(cmd.get("timeout", 60.0))
+ while time.time() < deadline and not connected.get("ok"):
+ for target in targets:
+ connected = service.connect_peer(target, timeout=8.0)
+ if connected.get("ok"):
+ break
+ if not connected.get("ok"):
+ time.sleep(1.0)
+ result_q.put({"ok": bool(connected.get("ok")), "connected": connected})
+ elif cmd.get("op") == "write":
+ path = os.path.join(sync_dir, cmd["path"])
+ parent = os.path.dirname(path)
+ if parent:
+ os.makedirs(parent, exist_ok=True)
+ with open(path, "wb") as handle:
+ handle.write(cmd["data"])
+ service.inventory.scan()
+ service.inventory.save()
+ if cmd.get("broadcast"):
+ service._broadcast_update(cmd["path"])
+ result_q.put({"ok": True, "op": "write"})
+ elif cmd.get("op") == "delete":
+ path = os.path.join(sync_dir, cmd["path"])
+ if os.path.exists(path):
+ os.remove(path)
+ service._monitor_once()
+ result_q.put({"ok": True, "op": "delete"})
+ elif cmd.get("op") == "scan":
+ service.inventory.scan()
+ service.inventory.save()
+ result_q.put({"ok": True, "op": "scan"})
+ elif cmd.get("op") == "announce":
+ service.announce_now()
+ result_q.put({"ok": True, "op": "announce"})
+ elif cmd.get("op") == "status":
+ result_q.put(
+ {
+ "ok": True,
+ "status": service.get_status(),
+ "files": service.list_files(),
+ "peers": service.list_peers(),
+ },
+ )
+
+
+def _peer_b_main(config_dir: str, sync_dir: str, ready_q, cmd_q, result_q) -> None:
+ _peer_worker(config_dir, sync_dir, ready_q, cmd_q, result_q)
+
+
+def _peer_a_main(
+ config_dir: str,
+ sync_dir: str,
+ peer_identity_hex: str,
+ peer_destination_hex: str,
+ ready_q,
+ cmd_q,
+ result_q,
+) -> None:
+ """Legacy two-peer client: connect once then idle on commands."""
+ import RNS
+
+ from rns_filesync.service import FileSyncService
+
+ RNS.loglevel = RNS.LOG_ERROR
+ RNS.Reticulum(config_dir)
+ identity = RNS.Identity()
+ service = FileSyncService(
+ identity=identity,
+ sync_directory=sync_dir,
+ reticulum=RNS.Reticulum.get_instance(),
+ )
+ service.start(monitor=True, announce_interval=8)
+ connected = {"ok": False}
+ targets = [peer_identity_hex, peer_destination_hex]
+ deadline = time.time() + 60.0
+ while time.time() < deadline and not connected.get("ok"):
+ for target in targets:
+ connected = service.connect_peer(target, timeout=8.0)
+ if connected.get("ok"):
+ break
+ if not connected.get("ok"):
+ time.sleep(1.0)
+ ready_q.put({"connected": connected, "identity": identity.hash.hex()})
+ while True:
+ cmd = cmd_q.get()
+ if cmd is None or cmd.get("op") == "stop":
+ service.stop()
+ result_q.put({"stopped": True})
+ break
+ if cmd.get("op") == "status":
+ result_q.put(
+ {"status": service.get_status(), "files": service.list_files()},
+ )
+
+
+class _PeerHandle:
+ def __init__(
+ self, name: str, root: Path, *, listen_port: int | None, peer_port: int | None
+ ):
+ self.name = name
+ self.dir = root / name
+ self.cfg = root / f"cfg_{name}"
+ self.dir.mkdir()
+ write_config(
+ self.cfg,
+ name=name,
+ listen_port=listen_port,
+ peer_port=peer_port,
+ )
+ ctx = mp.get_context("spawn")
+ self.ready = ctx.Queue()
+ self.cmd = ctx.Queue()
+ self.res = ctx.Queue()
+ self.proc = None
+ self.identity = None
+ self.destination = None
+
+ def start(self) -> None:
+ ctx = mp.get_context("spawn")
+ self.proc = ctx.Process(
+ target=_peer_worker,
+ args=(str(self.cfg), str(self.dir), self.ready, self.cmd, self.res),
+ daemon=True,
+ )
+ self.proc.start()
+ info = self.ready.get(timeout=45)
+ if not info.get("ok", True):
+ raise RuntimeError(f"peer {self.name} failed to start: {info}")
+ self.identity = info["identity"]
+ self.destination = info["destination"]
+
+ def connect(self, *targets: str, timeout: float = 60.0) -> dict:
+ self.cmd.put({"op": "connect", "targets": list(targets), "timeout": timeout})
+ result = self.res.get(timeout=timeout + 30.0)
+ if not result.get("ok"):
+ raise RuntimeError(f"peer {self.name} connect failed: {result}")
+ return result
+
+ def write(self, relpath: str, data: bytes, broadcast: bool = False) -> None:
+ self.cmd.put(
+ {"op": "write", "path": relpath, "data": data, "broadcast": broadcast},
+ )
+ assert self.res.get(timeout=30).get("ok")
+
+ def delete(self, relpath: str) -> None:
+ self.cmd.put({"op": "delete", "path": relpath})
+ assert self.res.get(timeout=30).get("ok")
+
+ def stop(self) -> None:
+ if self.proc is None:
+ return
+ try:
+ self.cmd.put({"op": "stop"})
+ self.res.get(timeout=10)
+ except Exception:
+ pass
+ self.proc.join(timeout=5)
+ if self.proc.is_alive():
+ self.proc.terminate()
+
+
+class TwoPeerHarness:
+ """Manage two FileSync peers in separate processes over TCP."""
+
+ def __init__(self, root: Path):
+ self.root = root
+ self.port = free_port()
+ self.dir_a = root / "a"
+ self.dir_b = root / "b"
+ self.cfg_a = root / "cfg_a"
+ self.cfg_b = root / "cfg_b"
+ self.dir_a.mkdir()
+ self.dir_b.mkdir()
+ write_config(self.cfg_b, name="b", listen_port=self.port, peer_port=None)
+ write_config(self.cfg_a, name="a", listen_port=None, peer_port=self.port)
+
+ ctx = mp.get_context("spawn")
+ self.ready_b = ctx.Queue()
+ self.cmd_b = ctx.Queue()
+ self.res_b = ctx.Queue()
+ self.ready_a = ctx.Queue()
+ self.cmd_a = ctx.Queue()
+ self.res_a = ctx.Queue()
+ self.proc_b = None
+ self.proc_a = None
+ self.peer_b_identity = None
+ self.peer_b_destination = None
+
+ def start(self) -> None:
+ ctx = mp.get_context("spawn")
+ self.proc_b = ctx.Process(
+ target=_peer_b_main,
+ args=(
+ str(self.cfg_b),
+ str(self.dir_b),
+ self.ready_b,
+ self.cmd_b,
+ self.res_b,
+ ),
+ daemon=True,
+ )
+ self.proc_b.start()
+ info = self.ready_b.get(timeout=30)
+ self.peer_b_identity = info["identity"]
+ self.peer_b_destination = info["destination"]
+ time.sleep(0.8)
+
+ self.proc_a = ctx.Process(
+ target=_peer_a_main,
+ args=(
+ str(self.cfg_a),
+ str(self.dir_a),
+ self.peer_b_identity,
+ self.peer_b_destination,
+ self.ready_a,
+ self.cmd_a,
+ self.res_a,
+ ),
+ daemon=True,
+ )
+ self.proc_a.start()
+ a_info = self.ready_a.get(timeout=90)
+ if not a_info.get("connected", {}).get("ok"):
+ raise RuntimeError(f"peer A failed to connect: {a_info}")
+
+ def b_write(self, relpath: str, data: bytes, broadcast: bool = False) -> None:
+ self.cmd_b.put(
+ {"op": "write", "path": relpath, "data": data, "broadcast": broadcast},
+ )
+ assert self.res_b.get(timeout=30).get("ok")
+
+ def b_delete(self, relpath: str) -> None:
+ self.cmd_b.put({"op": "delete", "path": relpath})
+ assert self.res_b.get(timeout=30).get("ok")
+
+ def stop(self) -> None:
+ for cmd, res, proc in (
+ (self.cmd_a, self.res_a, self.proc_a),
+ (self.cmd_b, self.res_b, self.proc_b),
+ ):
+ if proc is None:
+ continue
+ try:
+ cmd.put({"op": "stop"})
+ res.get(timeout=10)
+ except Exception:
+ pass
+ proc.join(timeout=5)
+ if proc.is_alive():
+ proc.terminate()
+
+
+class ThreePeerHarness:
+ """Star topology: hub listens, two leaves connect over TCP.
+
+ Verifies multi-peer links and hub fan-out (leaf write reaches the other leaf).
+ """
+
+ def __init__(self, root: Path):
+ self.root = root
+ self.port = free_port()
+ self.hub = _PeerHandle("hub", root, listen_port=self.port, peer_port=None)
+ self.leaf_a = _PeerHandle("leaf_a", root, listen_port=None, peer_port=self.port)
+ self.leaf_b = _PeerHandle("leaf_b", root, listen_port=None, peer_port=self.port)
+
+ @property
+ def dir_hub(self) -> Path:
+ return self.hub.dir
+
+ @property
+ def dir_a(self) -> Path:
+ return self.leaf_a.dir
+
+ @property
+ def dir_b(self) -> Path:
+ return self.leaf_b.dir
+
+ def start(self) -> None:
+ self.hub.start()
+ time.sleep(0.8)
+ self.leaf_a.start()
+ self.leaf_a.connect(self.hub.identity, self.hub.destination, timeout=60.0)
+ self.leaf_b.start()
+ self.leaf_b.connect(self.hub.identity, self.hub.destination, timeout=60.0)
+ time.sleep(0.5)
+
+ def hub_write(self, relpath: str, data: bytes, broadcast: bool = False) -> None:
+ self.hub.write(relpath, data, broadcast=broadcast)
+
+ def leaf_a_write(self, relpath: str, data: bytes, broadcast: bool = False) -> None:
+ self.leaf_a.write(relpath, data, broadcast=broadcast)
+
+ def leaf_b_write(self, relpath: str, data: bytes, broadcast: bool = False) -> None:
+ self.leaf_b.write(relpath, data, broadcast=broadcast)
+
+ def stop(self) -> None:
+ for peer in (self.leaf_a, self.leaf_b, self.hub):
+ peer.stop()

diff --git a/vendor/rns_filesync/tests/e2e/test_e2e_sync.py b/vendor/rns_filesync/tests/e2e/test_e2e_sync.py
new file mode 100644
index 00000000..49c82513
--- /dev/null
+++ b/vendor/rns_filesync/tests/e2e/test_e2e_sync.py
@@ -0,0 +1,68 @@
+"""End-to-end sync tests over two Reticulum processes linked by TCP."""
+
+from __future__ import annotations
+
+import pytest
+
+from tests.e2e.rns_helpers import TwoPeerHarness, wait_until
+
+pytestmark = [pytest.mark.e2e]
+
+
+@pytest.fixture
+def harness(tmp_path):
+ h = TwoPeerHarness(tmp_path)
+ # Seed file before connect so initial exchange pulls it.
+ (h.dir_b / "seed.txt").write_bytes(b"seed")
+ h.start()
+ yield h
+ h.stop()
+
+
+def test_e2e_full_file_sync(harness):
+ # seed.txt should arrive via connect-time exchange
+ assert wait_until(lambda: (harness.dir_a / "seed.txt").is_file(), timeout=45.0)
+ assert (harness.dir_a / "seed.txt").read_bytes() == b"seed"
+
+ harness.b_write("hello.txt", b"hello-filesync-e2e", broadcast=True)
+ assert wait_until(
+ lambda: (
+ (harness.dir_a / "hello.txt").is_file()
+ and (harness.dir_a / "hello.txt").read_bytes() == b"hello-filesync-e2e"
+ ),
+ timeout=45.0,
+ )
+
+
+def test_e2e_modify_uses_delta_path(tmp_path):
+ from rns_filesync.constants import BLOCK_SIZE
+
+ h = TwoPeerHarness(tmp_path)
+ original = b"X" * (BLOCK_SIZE + 100)
+ (h.dir_b / "big.bin").write_bytes(original)
+ h.start()
+ try:
+ assert wait_until(lambda: (h.dir_a / "big.bin").is_file(), timeout=60.0)
+ modified = b"Y" * BLOCK_SIZE + original[BLOCK_SIZE:]
+ h.b_write("big.bin", modified, broadcast=True)
+ assert wait_until(
+ lambda: (
+ (h.dir_a / "big.bin").is_file()
+ and (h.dir_a / "big.bin").read_bytes() == modified
+ ),
+ timeout=60.0,
+ )
+ finally:
+ h.stop()
+
+
+def test_e2e_delete_propagates(tmp_path):
+ h = TwoPeerHarness(tmp_path)
+ (h.dir_b / "gone.txt").write_text("temp")
+ h.start()
+ try:
+ assert wait_until(lambda: (h.dir_a / "gone.txt").is_file(), timeout=45.0)
+ h.b_delete("gone.txt")
+ assert wait_until(lambda: not (h.dir_a / "gone.txt").exists(), timeout=45.0)
+ finally:
+ h.stop()

diff --git a/vendor/rns_filesync/tests/e2e/test_e2e_three_peer.py b/vendor/rns_filesync/tests/e2e/test_e2e_three_peer.py
new file mode 100644
index 00000000..c6e5017f
--- /dev/null
+++ b/vendor/rns_filesync/tests/e2e/test_e2e_three_peer.py
@@ -0,0 +1,69 @@
+"""Three-peer sync: hub fan-out and multi-link behavior."""
+
+from __future__ import annotations
+
+import pytest
+
+from tests.e2e.rns_helpers import ThreePeerHarness, wait_until
+
+pytestmark = [pytest.mark.e2e]
+
+
+@pytest.fixture
+def three(tmp_path):
+ h = ThreePeerHarness(tmp_path)
+ (h.dir_hub / "seed.txt").write_bytes(b"hub-seed")
+ h.start()
+ yield h
+ h.stop()
+
+
+def test_e2e_three_peer_hub_seed_reaches_both_leaves(three):
+ assert wait_until(lambda: (three.dir_a / "seed.txt").is_file(), timeout=60.0)
+ assert wait_until(lambda: (three.dir_b / "seed.txt").is_file(), timeout=60.0)
+ assert (three.dir_a / "seed.txt").read_bytes() == b"hub-seed"
+ assert (three.dir_b / "seed.txt").read_bytes() == b"hub-seed"
+
+
+def test_e2e_three_peer_leaf_write_fans_out_via_hub(three):
+ assert wait_until(lambda: (three.dir_a / "seed.txt").is_file(), timeout=60.0)
+ assert wait_until(lambda: (three.dir_b / "seed.txt").is_file(), timeout=60.0)
+
+ three.leaf_a_write("from-a.txt", b"leaf-a-payload", broadcast=True)
+ assert wait_until(
+ lambda: (
+ (three.dir_hub / "from-a.txt").is_file()
+ and (three.dir_hub / "from-a.txt").read_bytes() == b"leaf-a-payload"
+ ),
+ timeout=60.0,
+ )
+ assert wait_until(
+ lambda: (
+ (three.dir_b / "from-a.txt").is_file()
+ and (three.dir_b / "from-a.txt").read_bytes() == b"leaf-a-payload"
+ ),
+ timeout=90.0,
+ )
+
+
+def test_e2e_three_peer_both_leaves_receive_hub_update(tmp_path):
+ h = ThreePeerHarness(tmp_path)
+ h.start()
+ try:
+ h.hub_write("broadcast.txt", b"to-both-leaves", broadcast=True)
+ assert wait_until(
+ lambda: (
+ (h.dir_a / "broadcast.txt").is_file()
+ and (h.dir_a / "broadcast.txt").read_bytes() == b"to-both-leaves"
+ ),
+ timeout=60.0,
+ )
+ assert wait_until(
+ lambda: (
+ (h.dir_b / "broadcast.txt").is_file()
+ and (h.dir_b / "broadcast.txt").read_bytes() == b"to-both-leaves"
+ ),
+ timeout=60.0,
+ )
+ finally:
+ h.stop()

diff --git a/vendor/rns_filesync/tests/exploratory/test_exploratory.py b/vendor/rns_filesync/tests/exploratory/test_exploratory.py
new file mode 100644
index 00000000..2153a37b
--- /dev/null
+++ b/vendor/rns_filesync/tests/exploratory/test_exploratory.py
@@ -0,0 +1,41 @@
+"""Exploratory stress tests using the two-peer TCP harness."""
+
+from __future__ import annotations
+
+import pytest
+
+from tests.e2e.rns_helpers import TwoPeerHarness, wait_until
+
+pytestmark = [pytest.mark.exploratory]
+
+
+def test_many_small_files(tmp_path):
+ h = TwoPeerHarness(tmp_path)
+ for i in range(25):
+ (h.dir_b / f"f{i}.txt").write_text(f"payload-{i}")
+ h.start()
+ try:
+ assert wait_until(
+ lambda: all((h.dir_a / f"f{i}.txt").is_file() for i in range(25)),
+ timeout=120.0,
+ )
+ finally:
+ h.stop()
+
+
+def test_nested_and_empty(tmp_path):
+ h = TwoPeerHarness(tmp_path)
+ nested = h.dir_b / "sub" / "dir"
+ nested.mkdir(parents=True)
+ (nested / "empty").write_bytes(b"")
+ (nested / "data").write_bytes(b"nested-data")
+ h.start()
+ try:
+ assert wait_until(
+ lambda: (h.dir_a / "sub" / "dir" / "data").is_file(),
+ timeout=90.0,
+ )
+ assert (h.dir_a / "sub" / "dir" / "empty").is_file()
+ assert (h.dir_a / "sub" / "dir" / "empty").stat().st_size == 0
+ finally:
+ h.stop()

diff --git a/vendor/rns_filesync/tests/exploratory/test_security_races.py b/vendor/rns_filesync/tests/exploratory/test_security_races.py
new file mode 100644
index 00000000..4a5b6ca0
--- /dev/null
+++ b/vendor/rns_filesync/tests/exploratory/test_security_races.py
@@ -0,0 +1,86 @@
+"""Exploratory race and malicious-client stress tests."""
+
+from __future__ import annotations
+
+import threading
+from types import SimpleNamespace
+
+import pytest
+
+from rns_filesync.paths import PathJailError, normalize_relpath
+from rns_filesync.permissions import PermissionStore
+
+pytestmark = [pytest.mark.exploratory]
+
+
+def test_concurrent_acl_mutations_stable():
+ store = PermissionStore()
+ store.add_rule("r:all")
+ barrier = threading.Barrier(8)
+ failures: list[str] = []
+
+ def mutate(n: int):
+ barrier.wait()
+ for i in range(100):
+ store.add_rule(f"w:{(n * 1000 + i):032x}")
+ store.block(f"{(n * 1000 + i + 7):032x}")
+ if store.check("ff" * 16, "write"):
+ # r:all does not grant write
+ failures.append("write granted via r:all")
+
+ threads = [threading.Thread(target=mutate, args=(i,)) for i in range(8)]
+ for thread in threads:
+ thread.start()
+ for thread in threads:
+ thread.join()
+ assert failures == []
+ assert store.check("aa" * 16, "read")
+ assert not store.check("aa" * 16, "write")
+
+
+def test_flood_path_normalize():
+ bad = [
+ "../x",
+ "a/../b/../c/../../d",
+ "/" + "a/" * 50 + "../etc/passwd",
+ "\x00" * 10,
+ "..",
+ "../" * 50 + "etc/passwd",
+ ]
+ for _ in range(1000):
+ for item in bad:
+ with pytest.raises(PathJailError):
+ normalize_relpath(item)
+
+
+def test_require_perm_under_identity_flapping(tmp_path):
+ from rns_filesync.service import FileSyncService
+
+ sync = tmp_path / "sync"
+ sync.mkdir()
+ perms = PermissionStore()
+ allowed = b"\x10" * 16
+ perms.add_rule(f"rwd:{allowed.hex()}")
+ svc = FileSyncService(
+ identity=SimpleNamespace(hash=b"\xaa" * 16),
+ sync_directory=str(sync),
+ permissions=perms,
+ )
+
+ class FlipLink:
+ def __init__(self):
+ self.on = False
+ self.destination = SimpleNamespace(hash=b"\x11" * 16)
+
+ def get_remote_identity(self):
+ self.on = not self.on
+ if self.on:
+ return SimpleNamespace(hash=allowed)
+ return None
+
+ link = FlipLink()
+ # Even under flapping identity, require_perm must never raise and must
+ # only allow when identity is present and authorized.
+ for _ in range(100):
+ result = svc._require_perm(link, "read")
+ assert isinstance(result, bool)

diff --git a/vendor/rns_filesync/tests/live/test_live_real_interfaces.py b/vendor/rns_filesync/tests/live/test_live_real_interfaces.py
new file mode 100644
index 00000000..d5249e58
--- /dev/null
+++ b/vendor/rns_filesync/tests/live/test_live_real_interfaces.py
@@ -0,0 +1,241 @@
+"""Live sync over real network interfaces.
+
+Strategies:
+- UDPInterface on free ports (real sockets, no AutoInterface conflict)
+- Optional shared-instance attach to the host Reticulum config
+- AutoInterface when not already bound by another stack
+"""
+
+from __future__ import annotations
+
+import multiprocessing as mp
+import os
+import queue
+import socket
+import textwrap
+import time
+from pathlib import Path
+
+import pytest
+
+from tests.e2e.rns_helpers import wait_until
+
+pytestmark = [pytest.mark.live]
+
+
+def _free_udp_port() -> int:
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
+ sock.bind(("127.0.0.1", 0))
+ return int(sock.getsockname()[1])
+
+
+def _write_udp_pair(cfg_a: Path, cfg_b: Path) -> None:
+ port_a = _free_udp_port()
+ port_b = _free_udp_port()
+ shared_a = 46000 + (os.getpid() % 500) * 2
+ shared_b = shared_a + 1
+
+ def write(path: Path, name: str, listen: int, peer: int, shared: int) -> None:
+ path.mkdir(parents=True, exist_ok=True)
+ (path / "storage").mkdir(exist_ok=True)
+ (path / "config").write_text(
+ textwrap.dedent(
+ f"""
+ [reticulum]
+ enable_transport = Yes
+ share_instance = No
+ shared_instance_port = {shared}
+ instance_name = filesync_live_udp_{name}_{listen}
+ panic_on_interface_error = No
+
+ [logging]
+ loglevel = 5
+
+ [interfaces]
+ [[UDP {name}]]
+ type = UDPInterface
+ enabled = yes
+ listen_ip = 127.0.0.1
+ listen_port = {listen}
+ forward_ip = 127.0.0.1
+ forward_port = {peer}
+ """,
+ ).strip()
+ + "\n",
+ )
+
+ write(cfg_a, "a", port_a, port_b, shared_a)
+ write(cfg_b, "b", port_b, port_a, shared_b)
+
+
+def _peer_b(config_dir: str, sync_dir: str, ready_q, cmd_q, result_q) -> None:
+ import traceback
+
+ try:
+ import RNS
+
+ from rns_filesync.service import FileSyncService
+
+ RNS.loglevel = RNS.LOG_ERROR
+ RNS.Reticulum(config_dir)
+ identity = RNS.Identity()
+ service = FileSyncService(
+ identity=identity,
+ sync_directory=sync_dir,
+ reticulum=RNS.Reticulum.get_instance(),
+ )
+ dest = service.start(monitor=True, announce_interval=8)
+ ready_q.put({"ok": True, "identity": identity.hash.hex(), "destination": dest})
+ while True:
+ try:
+ cmd = cmd_q.get(timeout=2.0)
+ except queue.Empty:
+ service.announce_now()
+ continue
+ if cmd is None or cmd.get("op") == "stop":
+ service.stop()
+ result_q.put({"stopped": True})
+ break
+ except Exception as exc:
+ ready_q.put({"ok": False, "error": str(exc), "trace": traceback.format_exc()})
+
+
+def _peer_a(
+ config_dir: str,
+ sync_dir: str,
+ peer_identity_hex: str,
+ peer_destination_hex: str,
+ ready_q,
+ cmd_q,
+ result_q,
+) -> None:
+ import traceback
+
+ try:
+ import RNS
+
+ from rns_filesync.service import FileSyncService
+
+ RNS.loglevel = RNS.LOG_ERROR
+ RNS.Reticulum(config_dir)
+ identity = RNS.Identity()
+ service = FileSyncService(
+ identity=identity,
+ sync_directory=sync_dir,
+ reticulum=RNS.Reticulum.get_instance(),
+ )
+ service.start(monitor=True, announce_interval=8)
+ connected = {"ok": False}
+ deadline = time.time() + 60.0
+ targets = [peer_identity_hex, peer_destination_hex]
+ while time.time() < deadline and not connected.get("ok"):
+ for target in targets:
+ connected = service.connect_peer(target, timeout=8.0)
+ if connected.get("ok"):
+ break
+ if not connected.get("ok"):
+ time.sleep(1.0)
+ ready_q.put(
+ {"ok": True, "connected": connected, "identity": identity.hash.hex()},
+ )
+ while True:
+ cmd = cmd_q.get()
+ if cmd is None or cmd.get("op") == "stop":
+ service.stop()
+ result_q.put({"stopped": True})
+ break
+ except Exception as exc:
+ ready_q.put({"ok": False, "error": str(exc), "trace": traceback.format_exc()})
+
+
+def _run_two_peer_live(tmp_path: Path, write_configs) -> None:
+ dir_a = tmp_path / "a"
+ dir_b = tmp_path / "b"
+ cfg_a = tmp_path / "cfg_a"
+ cfg_b = tmp_path / "cfg_b"
+ dir_a.mkdir()
+ dir_b.mkdir()
+ write_configs(cfg_a, cfg_b)
+ (dir_b / "live.txt").write_text("live-network-ok")
+
+ ctx = mp.get_context("spawn")
+ ready_b, cmd_b, res_b = ctx.Queue(), ctx.Queue(), ctx.Queue()
+ ready_a, cmd_a, res_a = ctx.Queue(), ctx.Queue(), ctx.Queue()
+
+ proc_b = ctx.Process(
+ target=_peer_b,
+ args=(str(cfg_b), str(dir_b), ready_b, cmd_b, res_b),
+ daemon=True,
+ )
+ proc_b.start()
+ info_b = ready_b.get(timeout=45)
+ assert info_b.get("ok"), info_b
+ time.sleep(1.0)
+
+ proc_a = ctx.Process(
+ target=_peer_a,
+ args=(
+ str(cfg_a),
+ str(dir_a),
+ info_b["identity"],
+ info_b["destination"],
+ ready_a,
+ cmd_a,
+ res_a,
+ ),
+ daemon=True,
+ )
+ proc_a.start()
+ try:
+ info_a = ready_a.get(timeout=90)
+ assert info_a.get("ok"), info_a
+ assert info_a.get("connected", {}).get("ok"), info_a
+ assert wait_until(
+ lambda: (
+ (dir_a / "live.txt").is_file()
+ and (dir_a / "live.txt").read_text() == "live-network-ok"
+ ),
+ timeout=60.0,
+ )
+ finally:
+ for cmd, res, proc in ((cmd_a, res_a, proc_a), (cmd_b, res_b, proc_b)):
+ try:
+ cmd.put({"op": "stop"})
+ res.get(timeout=10)
+ except Exception:
+ pass
+ proc.join(timeout=5)
+ if proc.is_alive():
+ proc.terminate()
+
+
+@pytest.mark.timeout(150)
+def test_live_udp_interface_two_peer_sync(tmp_path):
+ """Sync over real UDPInterface sockets (not isolated TCPServer pair)."""
+ _run_two_peer_live(tmp_path, _write_udp_pair)
+
+
+@pytest.mark.timeout(150)
+def test_live_tcp_harness_still_works(tmp_path):
+ from tests.e2e.rns_helpers import TwoPeerHarness
+
+ h = TwoPeerHarness(tmp_path)
+ (h.dir_b / "tcp-live.txt").write_text("tcp-ok")
+ h.start()
+ try:
+ assert wait_until(
+ lambda: (
+ (h.dir_a / "tcp-live.txt").is_file()
+ and (h.dir_a / "tcp-live.txt").read_text() == "tcp-ok"
+ ),
+ timeout=60.0,
+ )
+ finally:
+ h.stop()
+
+
+def test_live_host_reticulum_config_present():
+ path = Path(os.environ.get("RNS_FILESYNC_LIVE_CONFIG", Path.home() / ".reticulum"))
+ if not (path / "config").is_file():
+ pytest.skip("host Reticulum config not present")
+ assert (path / "config").is_file()

diff --git a/vendor/rns_filesync/tests/live/test_live_sync.py b/vendor/rns_filesync/tests/live/test_live_sync.py
new file mode 100644
index 00000000..14ad952b
--- /dev/null
+++ b/vendor/rns_filesync/tests/live/test_live_sync.py
@@ -0,0 +1,62 @@
+"""Compatibility live markers (TCP harness + host config presence)."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+import pytest
+
+from tests.e2e.rns_helpers import ThreePeerHarness, TwoPeerHarness, wait_until
+
+pytestmark = [pytest.mark.live]
+
+
+def test_live_two_peer_tcp(tmp_path):
+ h = TwoPeerHarness(tmp_path)
+ (h.dir_b / "live.txt").write_text("live-ok")
+ h.start()
+ try:
+ assert wait_until(
+ lambda: (
+ (h.dir_a / "live.txt").is_file()
+ and (h.dir_a / "live.txt").read_text() == "live-ok"
+ ),
+ timeout=90.0,
+ )
+ finally:
+ h.stop()
+
+
+@pytest.mark.timeout(180)
+def test_live_three_peer_tcp_fanout(tmp_path):
+ h = ThreePeerHarness(tmp_path)
+ (h.dir_hub / "hub-live.txt").write_text("three-ok")
+ h.start()
+ try:
+ assert wait_until(
+ lambda: (
+ (h.dir_a / "hub-live.txt").is_file()
+ and (h.dir_b / "hub-live.txt").is_file()
+ ),
+ timeout=90.0,
+ )
+ h.leaf_a_write("fan.txt", b"a-to-b-via-hub", broadcast=True)
+ assert wait_until(
+ lambda: (
+ (h.dir_hub / "fan.txt").is_file()
+ and (h.dir_b / "fan.txt").is_file()
+ and (h.dir_b / "fan.txt").read_bytes() == b"a-to-b-via-hub"
+ ),
+ timeout=90.0,
+ )
+ finally:
+ h.stop()
+
+
+def test_live_real_config_available():
+ override = os.environ.get("RNS_FILESYNC_LIVE_CONFIG")
+ path = Path(override) if override else Path.home() / ".reticulum"
+ if not (path / "config").is_file():
+ pytest.skip("no real Reticulum config")
+ assert (path / "config").is_file()

diff --git a/vendor/rns_filesync/tests/smoke/test_packaging_docker.py b/vendor/rns_filesync/tests/smoke/test_packaging_docker.py
new file mode 100644
index 00000000..c18c1793
--- /dev/null
+++ b/vendor/rns_filesync/tests/smoke/test_packaging_docker.py
@@ -0,0 +1,49 @@
+"""Smoke checks for packaging and docker assets."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+pytestmark = [pytest.mark.smoke, pytest.mark.unit]
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+@pytest.mark.parametrize(
+ "rel",
+ [
+ "packaging/systemd/rns-filesync.service",
+ "packaging/systemd/rns-filesync.user.service",
+ "packaging/openrc/rns-filesync",
+ "packaging/dinit/rns-filesync",
+ "packaging/runit/rns-filesync/run",
+ "packaging/sysusers.d/rns-filesync.conf",
+ "packaging/tmpfiles.d/rns-filesync.conf",
+ "docker/Dockerfile",
+ "docker/Dockerfile.build",
+ "docker/README.md",
+ ],
+)
+def test_packaging_and_docker_files_exist(rel):
+ path = ROOT / rel
+ assert path.is_file(), rel
+
+
+def test_systemd_unit_is_non_root_and_hardened():
+ text = (ROOT / "packaging/systemd/rns-filesync.service").read_text(encoding="utf-8")
+ assert "User=rns-filesync" in text
+ assert "NoNewPrivileges=true" in text
+ assert "ProtectSystem=strict" in text
+ assert "--no-repl" in text
+ assert "User=root" not in text
+
+
+def test_dockerfile_is_rootless_multistage():
+ text = (ROOT / "docker/Dockerfile").read_text(encoding="utf-8")
+ assert "AS builder" in text
+ assert "AS runtime" in text
+ assert "USER filesync" in text
+ assert "adduser" in text
+ assert "USER root" not in text.split("AS runtime", 1)[-1]

diff --git a/vendor/rns_filesync/tests/smoke/test_sideband_plugins.py b/vendor/rns_filesync/tests/smoke/test_sideband_plugins.py
new file mode 100644
index 00000000..b16ba732
--- /dev/null
+++ b/vendor/rns_filesync/tests/smoke/test_sideband_plugins.py
@@ -0,0 +1,107 @@
+"""Smoke checks for Sideband drop-in plugins."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+pytestmark = [pytest.mark.smoke, pytest.mark.unit]
+
+ROOT = Path(__file__).resolve().parents[2]
+SERVICE_PLUGIN = ROOT / "sideband" / "rns_filesync_service.py"
+COMMAND_PLUGIN = ROOT / "sideband" / "rns_filesync_command.py"
+
+
+class SidebandPlugin:
+ pass
+
+
+class SidebandServicePlugin(SidebandPlugin):
+ def __init__(self, sideband_core):
+ self.__sideband = sideband_core
+ self.__started = False
+ self.service_name = type(self).service_name
+
+ def start(self):
+ self.__started = True
+
+ def stop(self):
+ self.__started = False
+
+ def is_running(self):
+ return self.__started is True
+
+ def get_sideband(self):
+ return self.__sideband
+
+
+class SidebandCommandPlugin(SidebandPlugin):
+ def __init__(self, sideband_core):
+ self.__sideband = sideband_core
+ self.__started = False
+ self.command_name = type(self).command_name
+
+ def start(self):
+ self.__started = True
+
+ def stop(self):
+ self.__started = False
+
+ def is_running(self):
+ return self.__started is True
+
+ def get_sideband(self):
+ return self.__sideband
+
+ def handle_command(self, arguments):
+ raise NotImplementedError
+
+
+def _load_plugin(path: Path):
+ plugin_globals = {
+ "SidebandServicePlugin": SidebandServicePlugin,
+ "SidebandCommandPlugin": SidebandCommandPlugin,
+ }
+ exec(path.read_text(encoding="utf-8"), plugin_globals)
+ return plugin_globals["plugin_class"]
+
+
+def test_sideband_plugin_files_exist():
+ assert SERVICE_PLUGIN.is_file()
+ assert COMMAND_PLUGIN.is_file()
+
+
+def test_sideband_service_plugin_loads_like_sideband():
+ cls = _load_plugin(SERVICE_PLUGIN)
+ assert cls.service_name == "rns_filesync"
+ core = SimpleNamespace(
+ identity=None,
+ reticulum=None,
+ app_dir="/tmp",
+ active_service_plugins={},
+ )
+ plugin = cls(core)
+ assert plugin.service_name == "rns_filesync"
+ assert plugin.get_filesync() is None
+
+
+def test_sideband_command_plugin_loads_like_sideband():
+ cls = _load_plugin(COMMAND_PLUGIN)
+ assert cls.command_name == "filesync"
+ replies = []
+
+ class FakeCore:
+ active_service_plugins = {}
+
+ def send_message(self, text, destination, *args, **kwargs):
+ replies.append((text, destination))
+
+ plugin = cls(FakeCore())
+ plugin.start()
+ assert plugin.is_running()
+ plugin.handle_command([], SimpleNamespace(source_hash=b"\x01" * 16))
+ assert replies
+ assert "Usage" in replies[0][0]
+ plugin.stop()

diff --git a/vendor/rns_filesync/tests/smoke/test_smoke.py b/vendor/rns_filesync/tests/smoke/test_smoke.py
new file mode 100644
index 00000000..dcd8def3
--- /dev/null
+++ b/vendor/rns_filesync/tests/smoke/test_smoke.py
@@ -0,0 +1,26 @@
+"""Smoke tests for package import and CLI help."""
+
+import subprocess
+import sys
+
+import pytest
+
+pytestmark = pytest.mark.smoke
+
+
+def test_import_package():
+ from rns_filesync import FileSyncService, __version__
+
+ assert FileSyncService is not None
+ assert __version__
+
+
+def test_cli_help():
+ result = subprocess.run(
+ [sys.executable, "-m", "rns_filesync.cli", "--help"],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ assert result.returncode == 0
+ assert "directory" in result.stdout

diff --git a/vendor/rns_filesync/tests/smoke/test_version_api.py b/vendor/rns_filesync/tests/smoke/test_version_api.py
new file mode 100644
index 00000000..6af1aa78
--- /dev/null
+++ b/vendor/rns_filesync/tests/smoke/test_version_api.py
@@ -0,0 +1,144 @@
+"""Version and MeshChatX-facing API readiness checks."""
+
+from __future__ import annotations
+
+import importlib.metadata
+from pathlib import Path
+
+import pytest
+
+pytestmark = [pytest.mark.smoke, pytest.mark.unit]
+
+
+def test_version_is_1_0_0():
+ import rns_filesync
+
+ assert rns_filesync.__version__ == "1.0.0"
+
+
+def test_pyproject_version_matches_package():
+ import rns_filesync
+
+ root = Path(__file__).resolve().parents[2]
+ text = (root / "pyproject.toml").read_text(encoding="utf-8")
+ assert 'version = "1.0.0"' in text
+ assert rns_filesync.__version__ == "1.0.0"
+
+
+def test_distribution_metadata_when_installed():
+ try:
+ meta = importlib.metadata.version("rns-filesync")
+ except importlib.metadata.PackageNotFoundError:
+ pytest.skip("editable/distribution metadata unavailable")
+ assert meta == "1.0.0"
+
+
+def test_public_api_surface_for_meshchatx():
+ from rns_filesync import FileSyncService
+ from rns_filesync.permissions import PermissionStore
+
+ required = [
+ "start",
+ "stop",
+ "get_status",
+ "list_peers",
+ "list_files",
+ "connect_peer",
+ "disconnect_peer",
+ "browse_peer",
+ "download_file",
+ "announce_now",
+ ]
+ for name in required:
+ assert callable(getattr(FileSyncService, name, None)), name
+
+ # Callback attributes exist on instances constructed without reticulum.
+ from types import SimpleNamespace
+
+ svc = FileSyncService(
+ identity=SimpleNamespace(hash=b"\xaa" * 16),
+ sync_directory="/tmp",
+ permissions=PermissionStore(),
+ )
+ for attr in (
+ "on_peer_connected",
+ "on_peer_disconnected",
+ "on_sync_progress",
+ "on_file_updated",
+ "on_file_deleted",
+ "on_error",
+ ):
+ assert hasattr(svc, attr)
+
+
+def test_service_does_not_construct_reticulum_when_instance_provided(tmp_path):
+ """Embed contract: host-owned stack is reused."""
+ from types import SimpleNamespace
+ from unittest.mock import MagicMock
+
+ import RNS
+
+ from rns_filesync.service import FileSyncService
+
+ fake = MagicMock(name="HostReticulum")
+ existing = RNS.Reticulum.get_instance()
+ if existing is not None:
+ pytest.skip("reticulum already running in this process")
+
+ svc = FileSyncService(
+ identity=SimpleNamespace(hash=b"\xbb" * 16),
+ sync_directory=str(tmp_path),
+ reticulum=fake,
+ )
+ assert svc._ensure_reticulum() is fake
+ assert svc._own_reticulum is False
+
+
+def test_cli_module_importable():
+ from rns_filesync import cli
+
+ assert callable(cli.main)
+ assert callable(cli.build_parser)
+ parser = cli.build_parser()
+ help_text = parser.format_help()
+ assert "--rnsconfig" in help_text
+ assert "--config" in help_text
+ assert "--version" in help_text
+ assert "--verbose" in help_text
+
+
+def test_cli_version_flags(capsys):
+ from rns_filesync import cli
+ from rns_filesync._meta import __version__
+
+ with pytest.raises(SystemExit) as exited:
+ cli.build_parser().parse_args(["-v"])
+ assert exited.value.code == 0
+ out = capsys.readouterr().out
+ assert __version__ in out
+ assert "rns-filesync" in out
+
+ with pytest.raises(SystemExit) as exited:
+ cli.build_parser().parse_args(["--version"])
+ assert exited.value.code == 0
+ assert __version__ in capsys.readouterr().out
+
+
+def test_version_string_includes_baked_fields_when_set(monkeypatch):
+ import rns_filesync._meta as meta
+
+ monkeypatch.setattr(meta, "__version__", "1.0.0")
+ monkeypatch.setattr(meta, "BUILD_DATE", "2026-07-19T12:00:00Z")
+ monkeypatch.setattr(meta, "GIT_COMMIT", "deadbeef")
+ monkeypatch.setattr(meta, "GIT_DIRTY", "0")
+ text = meta.version_string()
+ assert text == "rns-filesync 1.0.0 | built 2026-07-19T12:00:00Z | commit deadbeef"
+
+
+def test_man_page_exists():
+ root = Path(__file__).resolve().parents[2]
+ man = root / "man" / "man1" / "rns-filesync.1"
+ assert man.is_file()
+ text = man.read_text(encoding="utf-8")
+ assert ".TH RNS-FILESYNC" in text
+ assert "--version" in text or "\\-\\-version" in text

diff --git a/vendor/rns_filesync/tests/unit/__init__.py b/vendor/rns_filesync/tests/unit/__init__.py
new file mode 100644
index 00000000..e69de29b

diff --git a/vendor/rns_filesync/tests/unit/test_config.py b/vendor/rns_filesync/tests/unit/test_config.py
new file mode 100644
index 00000000..4341c735
--- /dev/null
+++ b/vendor/rns_filesync/tests/unit/test_config.py
@@ -0,0 +1,39 @@
+"""Unit tests for FileSync config helpers."""
+
+import os
+
+import pytest
+
+from rns_filesync.config import (
+ allowed_sidecar_paths,
+ ensure_config,
+ load_config,
+ parse_csv_hashes,
+)
+
+pytestmark = pytest.mark.unit
+
+
+def test_ensure_and_load_config(tmp_path):
+ cfg_dir = tmp_path / "fsconf"
+ path = ensure_config(str(cfg_dir))
+ assert os.path.isfile(os.path.join(path, "config"))
+ loaded_dir, config = load_config(str(cfg_dir))
+ assert loaded_dir == path
+ assert "filesync" in config
+
+
+def test_allowed_sidecar_paths(tmp_path):
+ sync = tmp_path / "shared"
+ sync.mkdir()
+ paths = allowed_sidecar_paths(str(sync))
+ assert any(p.endswith(".allowed") for p in paths)
+ assert any(
+ p.endswith(os.path.join("shared", ".allowed")) or p.endswith("shared/.allowed")
+ for p in paths
+ )
+
+
+def test_parse_csv_hashes():
+ assert parse_csv_hashes("aa, bb") == ["aa", "bb"]
+ assert parse_csv_hashes(None) == []

diff --git a/vendor/rns_filesync/tests/unit/test_inventory.py b/vendor/rns_filesync/tests/unit/test_inventory.py
new file mode 100644
index 00000000..fc9e5ef3
--- /dev/null
+++ b/vendor/rns_filesync/tests/unit/test_inventory.py
@@ -0,0 +1,78 @@
+"""Unit tests for inventory and sync decisions."""
+
+import os
+
+import pytest
+
+from rns_filesync.constants import BLOCK_SIZE
+from rns_filesync.inventory import (
+ Inventory,
+ decide_sync_action,
+ differing_block_nums,
+ hash_blocks,
+ hash_file,
+)
+
+pytestmark = pytest.mark.unit
+
+
+def test_hash_file(tmp_path):
+ path = tmp_path / "a.bin"
+ path.write_bytes(b"hello")
+ assert hash_file(str(path)) == hash_file(str(path))
+ assert len(hash_file(str(path))) == 64
+
+
+def test_hash_blocks(tmp_path):
+ data = b"a" * (BLOCK_SIZE + 10)
+ path = tmp_path / "b.bin"
+ path.write_bytes(data)
+ blocks = hash_blocks(str(path))
+ assert len(blocks) == 2
+ assert blocks[0]["num"] == 0
+ assert blocks[1]["size"] == 10
+
+
+def test_differing_block_nums():
+ local = [{"num": 0, "hash": "aa"}, {"num": 1, "hash": "bb"}]
+ assert differing_block_nums(local, ["aa"]) == [1]
+ assert differing_block_nums(local, ["aa", "bb"]) == []
+
+
+def test_decide_sync_action():
+ assert decide_sync_action(None, {"hash": "a"}) == "request_full"
+ assert decide_sync_action({"hash": "a"}, {"hash": "a"}) == "skip"
+ assert (
+ decide_sync_action({"hash": "a", "size": 10}, {"hash": "b"}) == "request_delta"
+ )
+ assert decide_sync_action({"hash": "a"}, None) == "ignore"
+
+
+def test_inventory_scan_and_db(tmp_path):
+ sync = tmp_path / "sync"
+ sync.mkdir()
+ (sync / "f.txt").write_text("one")
+ inv = Inventory(str(sync))
+ found = inv.scan()
+ assert "f.txt" in found
+ inv.save()
+ inv2 = Inventory(str(sync))
+ assert inv2.load() == 1
+ assert inv2.get("f.txt")["hash"] == found["f.txt"]["hash"]
+
+
+def test_inventory_mtime_fast_path(tmp_path):
+ sync = tmp_path / "sync"
+ sync.mkdir()
+ path = sync / "f.txt"
+ path.write_text("one")
+ inv = Inventory(str(sync))
+ first = inv.scan()
+ # Second scan should reuse hash when size+mtime unchanged
+ second = inv.scan()
+ assert first["f.txt"]["hash"] == second["f.txt"]["hash"]
+ path.write_text("two")
+ # Ensure mtime changes on some filesystems
+ os.utime(path, None)
+ third = inv.scan()
+ assert third["f.txt"]["hash"] != first["f.txt"]["hash"]

diff --git a/vendor/rns_filesync/tests/unit/test_multi_peer_links.py b/vendor/rns_filesync/tests/unit/test_multi_peer_links.py
new file mode 100644
index 00000000..209d51eb
--- /dev/null
+++ b/vendor/rns_filesync/tests/unit/test_multi_peer_links.py
@@ -0,0 +1,109 @@
+"""Multi-peer link registration must not collapse unidentified inbound peers."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+
+from rns_filesync.service import FileSyncService
+
+pytestmark = [pytest.mark.unit, pytest.mark.acceptance]
+
+
+class _FakeLink:
+ def __init__(self, identity_hash: bytes | None = None):
+ self._identity = None
+ if identity_hash is not None:
+ self._identity = SimpleNamespace(hash=identity_hash)
+ # Shared local destination hash mimics inbound SINGLE links.
+ self.destination = SimpleNamespace(hash=b"\x11" * 16)
+ self.status = 2 # RNS.Link.ACTIVE
+ self._identified_cb = None
+ self.torn_down = False
+
+ def get_remote_identity(self):
+ return self._identity
+
+ def set_remote_identified_callback(self, cb):
+ self._identified_cb = cb
+
+ def set_link_closed_callback(self, _cb):
+ return None
+
+ def set_packet_callback(self, _cb):
+ return None
+
+ def set_resource_strategy(self, _strategy):
+ return None
+
+ def set_resource_callback(self, _cb):
+ return None
+
+ def set_resource_started_callback(self, _cb):
+ return None
+
+ def set_resource_concluded_callback(self, _cb):
+ return None
+
+ def teardown(self):
+ self.torn_down = True
+
+ def identify(self, identity_hash: bytes):
+ self._identity = SimpleNamespace(hash=identity_hash)
+ if self._identified_cb is not None:
+ self._identified_cb(self, self._identity)
+
+
+def _open_service(tmp_path):
+ sync = tmp_path / "sync"
+ sync.mkdir()
+ svc = FileSyncService(
+ identity=SimpleNamespace(hash=b"\xaa" * 16),
+ sync_directory=str(sync),
+ )
+ svc._send = lambda link, payload: None
+ return svc
+
+
+def test_two_unidentified_inbound_links_keep_distinct_slots(tmp_path):
+ svc = _open_service(tmp_path)
+ leaf_a = _FakeLink(None)
+ leaf_b = _FakeLink(None)
+ # Same destination.hash on both: old bug keyed both as one peer.
+ assert leaf_a.destination.hash == leaf_b.destination.hash
+
+ svc._on_link_established(leaf_a)
+ svc._on_link_established(leaf_b)
+
+ assert len(svc._links) == 2
+ assert all(key.startswith("pending:") for key in svc._links)
+ assert set(svc._links.values()) == {leaf_a, leaf_b}
+
+ leaf_a.identify(b"\x01" * 16)
+ leaf_b.identify(b"\x02" * 16)
+
+ assert len(svc._links) == 2
+ assert (b"\x01" * 16).hex() in svc._links
+ assert (b"\x02" * 16).hex() in svc._links
+ assert set(svc._links.values()) == {leaf_a, leaf_b}
+
+
+def test_broadcast_reaches_both_provisional_peers(tmp_path):
+ svc = _open_service(tmp_path)
+ (tmp_path / "sync" / "note.txt").write_text("hi")
+ svc.inventory.scan()
+ svc.inventory.save()
+
+ leaf_a = _FakeLink(None)
+ leaf_b = _FakeLink(None)
+ leaf_a.packets = []
+ leaf_b.packets = []
+ svc._send = lambda link, payload: link.packets.append(payload)
+
+ svc._on_link_established(leaf_a)
+ svc._on_link_established(leaf_b)
+ svc._broadcast_update("note.txt")
+
+ assert len(leaf_a.packets) == 1
+ assert len(leaf_b.packets) == 1

diff --git a/vendor/rns_filesync/tests/unit/test_paths.py b/vendor/rns_filesync/tests/unit/test_paths.py
new file mode 100644
index 00000000..84bb4d43
--- /dev/null
+++ b/vendor/rns_filesync/tests/unit/test_paths.py
@@ -0,0 +1,63 @@
+"""Unit tests for path jail."""
+
+import os
+
+import pytest
+
+from rns_filesync.paths import (
+ PathJailError,
+ normalize_relpath,
+ relative_to_root,
+ resolve_under_root,
+)
+
+pytestmark = pytest.mark.unit
+
+
+def test_normalize_relpath_ok():
+ assert normalize_relpath("a/b.txt") == os.path.normpath("a/b.txt")
+
+
+def test_normalize_rejects_absolute():
+ with pytest.raises(PathJailError):
+ normalize_relpath("/etc/passwd")
+
+
+def test_normalize_rejects_traversal():
+ with pytest.raises(PathJailError):
+ normalize_relpath("../secret")
+ with pytest.raises(PathJailError):
+ normalize_relpath("a/../../b")
+
+
+def test_normalize_rejects_null():
+ with pytest.raises(PathJailError):
+ normalize_relpath("a\x00b")
+
+
+def test_resolve_under_root(tmp_path):
+ root = tmp_path / "sync"
+ root.mkdir()
+ nested = root / "dir"
+ nested.mkdir()
+ target = nested / "file.txt"
+ target.write_text("x")
+ resolved = resolve_under_root(str(root), "dir/file.txt")
+ assert resolved == str(target.resolve())
+
+
+def test_resolve_blocks_escape(tmp_path):
+ root = tmp_path / "sync"
+ root.mkdir()
+ outside = tmp_path / "outside.txt"
+ outside.write_text("nope")
+ with pytest.raises(PathJailError):
+ resolve_under_root(str(root), "../outside.txt")
+
+
+def test_relative_to_root(tmp_path):
+ root = tmp_path / "sync"
+ root.mkdir()
+ path = root / "x.bin"
+ path.write_bytes(b"1")
+ assert relative_to_root(str(root), str(path)) == "x.bin"

diff --git a/vendor/rns_filesync/tests/unit/test_permissions.py b/vendor/rns_filesync/tests/unit/test_permissions.py
new file mode 100644
index 00000000..4d52c215
--- /dev/null
+++ b/vendor/rns_filesync/tests/unit/test_permissions.py
@@ -0,0 +1,79 @@
+"""Unit tests for rngit-style permissions."""
+
+import pytest
+
+from rns_filesync.permissions import PermissionStore
+
+pytestmark = pytest.mark.unit
+
+
+def test_open_when_empty():
+ store = PermissionStore()
+ assert not store.enabled
+ assert store.check(b"\x00" * 16, "write")
+ assert store.can_connect(b"\x00" * 16)
+
+
+def test_rule_enforces_deny_by_default():
+ store = PermissionStore()
+ peer = "ab" * 16
+ assert store.add_rule(f"rw:{peer}")
+ assert store.enabled
+ assert store.check(peer, "read")
+ assert store.check(peer, "write")
+ assert not store.check(peer, "delete")
+ assert store.can_connect(peer)
+ assert not store.can_connect("cd" * 16)
+ assert not store.check("cd" * 16, "read")
+
+
+def test_all_and_none_targets():
+ store = PermissionStore()
+ store.add_rule("r:all")
+ assert store.check("ff" * 16, "read")
+ assert not store.check("ff" * 16, "write")
+
+ store2 = PermissionStore()
+ store2.add_rule("w:none")
+ assert store2.enabled
+ assert not store2.check("aa" * 16, "write")
+
+
+def test_admin_and_aliases():
+ store = PermissionStore()
+ store.set_alias("alice", "ab" * 16)
+ store.add_rule("adm:alice")
+ assert store.check("ab" * 16, "delete")
+ assert store.can_connect("ab" * 16)
+
+
+def test_blocked():
+ store = PermissionStore()
+ store.add_rule("r:all")
+ store.block("ab" * 16)
+ assert not store.check("ab" * 16, "read")
+ assert not store.can_connect("ab" * 16)
+
+
+def test_load_allowed_file(tmp_path):
+ path = tmp_path / "share.allowed"
+ path.write_text("# comment\nr:all\nw:" + ("aa" * 16) + "\n")
+ store = PermissionStore()
+ assert store.load_file(str(path)) == 2
+ assert store.check("ff" * 16, "read")
+ assert store.check("aa" * 16, "write")
+
+
+def test_legacy_hash_line(tmp_path):
+ path = tmp_path / "legacy"
+ path.write_text(("aa" * 16) + " read,write\n")
+ store = PermissionStore()
+ assert store.load_file(str(path)) == 1
+ assert store.check("aa" * 16, "write")
+
+
+def test_access_csv():
+ store = PermissionStore()
+ assert store.load_access_value("r:all, w:" + ("bb" * 16)) == 2
+ assert store.check("cc" * 16, "read")
+ assert store.check("bb" * 16, "write")

diff --git a/vendor/rns_filesync/tests/unit/test_protocol_transfer.py b/vendor/rns_filesync/tests/unit/test_protocol_transfer.py
new file mode 100644
index 00000000..d5e50497
--- /dev/null
+++ b/vendor/rns_filesync/tests/unit/test_protocol_transfer.py
@@ -0,0 +1,96 @@
+"""Unit tests for protocol and transfer helpers."""
+
+import io
+
+import pytest
+
+from rns_filesync import protocol
+from rns_filesync.constants import BLOCK_SIZE
+from rns_filesync.transfer import (
+ apply_delta_blocks,
+ build_delta_payload,
+ commit_received_file,
+ create_empty_file,
+ write_bytes_atomic,
+)
+
+pytestmark = pytest.mark.unit
+
+
+def test_protocol_roundtrip():
+ raw = protocol.make_file_request("a/b.txt")
+ msg = protocol.decode_message(raw)
+ assert msg["type"] == protocol.MSG_FILE_REQUEST
+ assert msg["path"] == "a/b.txt"
+
+
+def test_protocol_rejects_unknown():
+ bad = protocol.encode_message({"type": "nope"})
+ with pytest.raises(protocol.ProtocolError):
+ protocol.decode_message(bad)
+
+
+def test_write_and_empty(tmp_path):
+ root = tmp_path / "sync"
+ root.mkdir()
+ path = write_bytes_atomic(str(root), "nested/x.bin", b"abc")
+ assert open(path, "rb").read() == b"abc"
+ empty = create_empty_file(str(root), "empty.txt")
+ assert os_path_size(empty) == 0
+
+
+def os_path_size(path: str) -> int:
+ import os
+
+ return os.path.getsize(path)
+
+
+def test_delta_apply(tmp_path):
+ import os
+
+ root = tmp_path / "sync"
+ root.mkdir()
+ original = b"A" * BLOCK_SIZE + b"B" * BLOCK_SIZE + b"C" * 20
+ write_bytes_atomic(str(root), "f.bin", original)
+ dest = os.path.join(root, "f.bin")
+ with open(dest, "r+b") as handle:
+ handle.seek(BLOCK_SIZE)
+ handle.write(b"\x00" * BLOCK_SIZE)
+ peer = tmp_path / "peer.bin"
+ peer.write_bytes(original)
+ payload = build_delta_payload(str(peer), [1])
+ apply_delta_blocks(
+ str(root),
+ "f.bin",
+ [1],
+ io.BytesIO(payload),
+ expected_size=len(original),
+ )
+ assert open(dest, "rb").read() == original
+
+
+def test_commit_full_and_verify(tmp_path):
+ root = tmp_path / "sync"
+ root.mkdir()
+ data = b"payload-data"
+ import hashlib
+ import tempfile
+
+ digest = hashlib.sha256(data).hexdigest()
+ fd, tmp = tempfile.mkstemp(dir=root)
+ os_close_write(fd, tmp, data)
+ commit_received_file(
+ str(root),
+ "out.bin",
+ mode="full",
+ resource_data=open(tmp, "rb"),
+ expected_hash=digest,
+ )
+ assert (root / "out.bin").read_bytes() == data
+
+
+def os_close_write(fd, path, data):
+ import os
+
+ with os.fdopen(fd, "wb") as handle:
+ handle.write(data)

diff --git a/visualiser-wasm/internal/graph/full.go b/visualiser-wasm/internal/graph/full.go
index 9049e8d1..71e9f1c2 100644
--- a/visualiser-wasm/internal/graph/full.go
+++ b/visualiser-wasm/internal/graph/full.go
@@ -177,7 +177,7 @@ func BuildFullGraph(req FullRequest) FullResult {
if !entry.Online {
col = edgeOffline(req.DarkMode)
}
- addEdge(EdgeOut{ID: eid, From: "me", To: entry.Name, Color: col, Width: 3, Hidden: false}, 200)
+ addEdge(EdgeOut{ID: eid, From: "me", To: entry.Name, Color: col, Width: 3, Hidden: false}, 300)
}
}
@@ -214,7 +214,7 @@ func BuildFullGraph(req FullRequest) FullResult {
addNode(node, 2.5, false)
if _, ok := seen["me"]; ok {
eid := "me~" + entry.Name
- addEdge(EdgeOut{ID: eid, From: "me", To: entry.Name, Color: edgeDirect(req.DarkMode), Width: 3, Hidden: false}, 200)
+ addEdge(EdgeOut{ID: eid, From: "me", To: entry.Name, Color: edgeDirect(req.DarkMode), Width: 3, Hidden: false}, 300)
}
}
@@ -274,9 +274,9 @@ func BuildFullGraph(req FullRequest) FullResult {
addNode(n, 1, false)
}
for _, e := range pathRes.Edges {
- length := 180.0
+ length := 300.0
if e.Width >= 2 {
- length = 150
+ length = 260
}
addEdge(e, length)
}

diff --git a/visualiser-wasm/internal/layout/force.go b/visualiser-wasm/internal/layout/force.go
index 9239f5cf..d4b36f63 100644
--- a/visualiser-wasm/internal/layout/force.go
+++ b/visualiser-wasm/internal/layout/force.go
@@ -86,7 +86,7 @@ func Settle(req Request) Result {
}
repulsion := req.Repulsion
if repulsion == 0 {
- repulsion = 1200
+ repulsion = 1800
}
springK := req.SpringK
if springK == 0 {
@@ -136,12 +136,12 @@ func Settle(req Request) Result {
}
length := e.Length
if length <= 0 {
- length = 180
+ length = 280
}
springs = append(springs, spring{a: ai, b: bi, len: length})
}
- cellSize := 160.0
+ cellSize := 220.0
for step := 0; step < iters; step++ {
fx := make([]float64, n)
fy := make([]float64, n)

diff --git a/visualiser-wasm/internal/scene/scene.go b/visualiser-wasm/internal/scene/scene.go
index e370b3c2..59c0f314 100644
--- a/visualiser-wasm/internal/scene/scene.go
+++ b/visualiser-wasm/internal/scene/scene.go
@@ -260,9 +260,11 @@ func (s *Scene) Tick(steps int) {
layoutEdges := make([]layout.Edge, 0, len(s.edges))
for i := range s.edges {
e := &s.edges[i]
- length := 200.0
+ // WebGL node radii are ~18-48. Rest lengths must clear diameters
+ // like vis-network springLength 200 does for smaller canvas glyphs.
+ length := 300.0
if e.Width >= 2.5 {
- length = 170
+ length = 260
}
layoutEdges = append(layoutEdges, layout.Edge{
From: e.From,
@@ -277,9 +279,9 @@ func (s *Scene) Tick(steps int) {
Edges: layoutEdges,
Iterations: steps,
Gravity: -1,
- Repulsion: 550,
- SpringK: 0.018,
- Damping: 0.52,
+ Repulsion: 1800,
+ SpringK: 0.014,
+ Damping: 0.58,
MaxSpeed: 6,
})
const restSpeed = 0.12

diff --git a/visualiser-wasm/internal/scene/scene_test.go b/visualiser-wasm/internal/scene/scene_test.go
index baf1f2b2..5e4c54d4 100644
--- a/visualiser-wasm/internal/scene/scene_test.go
+++ b/visualiser-wasm/internal/scene/scene_test.go
@@ -109,8 +109,8 @@ func TestTickPersistsVelocityAndSettles(t *testing.T) {
Nodes: []Node{
{ID: "me", X: 0, Y: 0, Kind: KindMe, Fixed: true, Mass: 4},
// Start near spring rest length so live ticks should calm quickly.
- {ID: "a", X: 170, Y: 0, Kind: KindPeer, Mass: 1},
- {ID: "b", X: -170, Y: 0, Kind: KindPeer, Mass: 1},
+ {ID: "a", X: 260, Y: 0, Kind: KindPeer, Mass: 1},
+ {ID: "b", X: -260, Y: 0, Kind: KindPeer, Mass: 1},
},
Edges: []Edge{
{From: "me", To: "a", Width: 3},


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────